From fba0a12cdf40d67c3d6c2d8f96a1ee491415dc0e Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Tue, 22 Sep 2026 06:03:39 +0200 Subject: [PATCH 01/14] chore(sap): pin W0 source census and falsifiers Source heads: lance-graph f87081c67ca4de7c03bb975d17baf22d816f6790; SIMAF a420b48822e91df39e7b170a711eddf571e79db5; SIMAFPort 381d6c2e6ce47765a6460268bf15b9cec46ff2be; SMB-Core-Middleware 363314048483c3883d2734cbe5cd51d10fb5e8a6; SiMAF-rs empty; odoo-rs ef03731c00f5e2796df3185bd69526153af7e253 (head only, deferred). CATS has 23 leaf fields: string, pernr_d, kunnr, ps_posid, aufnr, catsquantity, lstar, abap_bool. Native DDIC scale is not defined in this corpus. ABAP hashes named values; SMB hashes bare normalized values, and has int TenantId plus additional ApprovalStatus. No silent parity claim. ClassView is cold metadata; Quack GroupBy already lowers onto mask-risc borrowed LaneRef and i32-to-i64 sum. CascadeShape is a 12-byte facet grouping, not a fit for lossless CATS quantities or CHAR24. No implementation in W0. From 9daafb15c1c4d2e5bc1bf317c48ac2432dffb7f3 Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Tue, 22 Sep 2026 06:03:41 +0200 Subject: [PATCH 02/14] feat(sap): harvest CATS fields into the ClassView basis --- Cargo.toml | 1 + crates/lance-graph-sap/Cargo.toml | 14 + crates/lance-graph-sap/schema.tsv | 24 ++ crates/lance-graph-sap/sources.tsv | 11 + crates/lance-graph-sap/src/lib.rs | 3 + crates/lance-graph-sap/src/schema.rs | 322 ++++++++++++++++++ .../lance-graph-sap/tools/verify_sources.py | 50 +++ 7 files changed, 425 insertions(+) create mode 100644 crates/lance-graph-sap/Cargo.toml create mode 100644 crates/lance-graph-sap/schema.tsv create mode 100644 crates/lance-graph-sap/sources.tsv create mode 100644 crates/lance-graph-sap/src/lib.rs create mode 100644 crates/lance-graph-sap/src/schema.rs create mode 100644 crates/lance-graph-sap/tools/verify_sources.py diff --git a/Cargo.toml b/Cargo.toml index 32ab3abb3..f27efe77e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "crates/lance-graph-hydrate", ] exclude = [ + "crates/lance-graph-sap", # Python bindings (upstream-inherited, opt-in via --manifest-path) "crates/lance-graph-python", "crates/lance-graph-codec-research", diff --git a/crates/lance-graph-sap/Cargo.toml b/crates/lance-graph-sap/Cargo.toml new file mode 100644 index 000000000..59025a10e --- /dev/null +++ b/crates/lance-graph-sap/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lance-graph-sap" +version = "0.1.0" +edition = "2021" +publish = false +description = "Source-pinned CATS boundary adapters over ClassView and Quack" + +[workspace] + +[dependencies] +lance-graph-contract = { path = "../lance-graph-contract" } +lance-graph-mask-risc = { path = "../lance-graph-mask-risc" } +lance-graph-quack = { path = "../lance-graph-quack" } + diff --git a/crates/lance-graph-sap/schema.tsv b/crates/lance-graph-sap/schema.tsv new file mode 100644 index 000000000..048bf763f --- /dev/null +++ b/crates/lance-graph-sap/schema.tsv @@ -0,0 +1,24 @@ +ordinal abap_group abap_name native_type csharp_name optional width carrier +0 ty_s_entry_header entry_id string EntryID false U32 +1 ty_s_entry_header source_system string SourceSystem false U32 +2 ty_s_entry_header entry_type string EntryType false U32 +3 ty_s_entry_header tenant_id string TenantID false U32 +4 ty_s_entry_header timestamp_utc string TimestampUTC false U64 +5 ty_s_time_entry_details employee_number pernr_d EmployeeNumber false 8 U32 +6 ty_s_time_entry_details customer_number kunnr CustomerNumber true 10 U32 +7 ty_s_time_entry_details project_code ps_posid ProjectCode true 24 U32 +8 ty_s_time_entry_details task_code aufnr TaskCode true 12 U32 +9 ty_s_time_entry_details work_date_utc string WorkDateUTC false U64 +10 ty_s_time_entry_details hours_logged catsquantity HoursLogged false I32 +11 ty_s_time_entry_details activity_type lstar ActivityType false 6 U32 +12 ty_s_time_entry_details billing_indicator string BillingIndicator false U32 +13 ty_s_time_sanitization sanitized_input_date string SanitizedInputDate false U64 +14 ty_s_time_sanitization original_input_date string OriginalInputDate false U32 +15 ty_s_time_sanitization date_validation_status string DateValidationStatus false U32 +16 ty_s_time_sanitization sanitization_reason string SanitizationReason true U32 +17 ty_s_security_metadata encryption_indicator abap_bool EncryptionIndicator false U32 +18 ty_s_security_metadata data_hash string DataHash false U32 +19 ty_s_security_metadata compliance_label string ComplianceLabel false U32 +20 ty_s_additional_metadata notes string Notes true U32 +21 ty_s_additional_metadata approver_employee_num pernr_d ApproverEmployeeNumber true 8 U32 +22 ty_s_additional_metadata approval_timestamp_utc string ApprovalTimestampUTC true U64 diff --git a/crates/lance-graph-sap/sources.tsv b/crates/lance-graph-sap/sources.tsv new file mode 100644 index 000000000..11dcc9545 --- /dev/null +++ b/crates/lance-graph-sap/sources.tsv @@ -0,0 +1,11 @@ +repo commit path blob +SIMAF a420b48822e91df39e7b170a711eddf571e79db5 Schema/UniversalDtoPoc.TimeTracking.txt 5cfc83701133aef096c1266188e59d7b367a67f7 +SIMAFPort 381d6c2e6ce47765a6460268bf15b9cec46ff2be src/UniversalDtoPoc/TimeTracking/TimeTrackingDtos.cs 49f91dd26571bd8b48d7ce1fd5fb9591cd5b2801 +SMB-Core-Middleware 363314048483c3883d2734cbe5cd51d10fb5e8a6 src/Core/Infrastructure/TimeTracking/TimeTrackingHasher.cs bafb6955634b84611575e7fd9d747095c7cffef0 +SIMAF a420b48822e91df39e7b170a711eddf571e79db5 Core/Interfaces/zif_simaf_dto.abap 8db13ebd87407e4c151924539c0213cd5d1cbac9 +SIMAF a420b48822e91df39e7b170a711eddf571e79db5 DTO/zcl_simaf_dto_base.abap e1acafe616cb87f0e3ea4fdf862b3e8650c796ad +SIMAF a420b48822e91df39e7b170a711eddf571e79db5 DTO/zcl_simaf_dto_document.abap 18fa9afdb5ecd47652b5f65fbfb0331b3f057234 +SIMAFPort 381d6c2e6ce47765a6460268bf15b9cec46ff2be V4/complete/CLASSES/TimeTracking/ZCL_TIME_TRACKING_DTO_POC.clas.abap.txt ba2b3cdd087a46b659d7527fa5f8ad9e1c20c31a +SMB-Core-Middleware 363314048483c3883d2734cbe5cd51d10fb5e8a6 src/Core/Interfaces/TimeTracking/ITimeTrackingService.cs 44d84aa4494bb103d25c91719190f2a91ff4fd8a +SIMAFPort 381d6c2e6ce47765a6460268bf15b9cec46ff2be V4/complete/CLASSES/TimeTracking/ZCL_DTO_HASHER.clas.abap.txt 19f4e8cd0edbe909cd8e613a686011d809b1bf7e +SIMAFPort 381d6c2e6ce47765a6460268bf15b9cec46ff2be V4/complete/CLASSES/TimeTracking/ZCL_TIME_DTO_PROCESSOR.clas.abap.txt 392fea8b292f8b6cb1a9a52ebdefd081e435a895 diff --git a/crates/lance-graph-sap/src/lib.rs b/crates/lance-graph-sap/src/lib.rs new file mode 100644 index 000000000..1157e247a --- /dev/null +++ b/crates/lance-graph-sap/src/lib.rs @@ -0,0 +1,3 @@ +//! CATS boundary adapters. Names and source types live in the cold schema; +//! execution borrows the existing mask-RISC ABI. No SAP evaluator. +pub mod schema; diff --git a/crates/lance-graph-sap/src/schema.rs b/crates/lance-graph-sap/src/schema.rs new file mode 100644 index 000000000..fa86959b3 --- /dev/null +++ b/crates/lance-graph-sap/src/schema.rs @@ -0,0 +1,322 @@ +//! The harvested 23-field CATS schema, ordered by the ABAP leaf declarations. +//! Ordinals are append-only. DDIC scale, ALPHA exits and localized labels are +//! absent: none is defined by the pinned source. Widths come from the C# mirror. +use lance_graph_contract::class_view::{ClassId, ClassView}; +use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; +use lance_graph_mask_risc::LaneKind; +use lance_graph_quack::Col; + +pub const FIELD_COUNT: usize = 23; +/// Native type is cold source/domain metadata, not an execution type tag. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FieldDescriptor { + pub ordinal: Col, + pub abap_group: &'static str, + pub technical_name: &'static str, + pub csharp_name: &'static str, + pub native_type: &'static str, + pub optional: bool, + pub width: Option, + pub carrier: LaneKind, +} + +pub const FIELDS: [FieldDescriptor; FIELD_COUNT] = [ + FieldDescriptor { + ordinal: Col(0), + abap_group: "ty_s_entry_header", + technical_name: "entry_id", + csharp_name: "EntryID", + native_type: "string", + optional: false, + width: None, + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(1), + abap_group: "ty_s_entry_header", + technical_name: "source_system", + csharp_name: "SourceSystem", + native_type: "string", + optional: false, + width: None, + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(2), + abap_group: "ty_s_entry_header", + technical_name: "entry_type", + csharp_name: "EntryType", + native_type: "string", + optional: false, + width: None, + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(3), + abap_group: "ty_s_entry_header", + technical_name: "tenant_id", + csharp_name: "TenantID", + native_type: "string", + optional: false, + width: None, + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(4), + abap_group: "ty_s_entry_header", + technical_name: "timestamp_utc", + csharp_name: "TimestampUTC", + native_type: "string", + optional: false, + width: None, + carrier: LaneKind::U64, + }, + FieldDescriptor { + ordinal: Col(5), + abap_group: "ty_s_time_entry_details", + technical_name: "employee_number", + csharp_name: "EmployeeNumber", + native_type: "pernr_d", + optional: false, + width: Some(8), + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(6), + abap_group: "ty_s_time_entry_details", + technical_name: "customer_number", + csharp_name: "CustomerNumber", + native_type: "kunnr", + optional: true, + width: Some(10), + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(7), + abap_group: "ty_s_time_entry_details", + technical_name: "project_code", + csharp_name: "ProjectCode", + native_type: "ps_posid", + optional: true, + width: Some(24), + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(8), + abap_group: "ty_s_time_entry_details", + technical_name: "task_code", + csharp_name: "TaskCode", + native_type: "aufnr", + optional: true, + width: Some(12), + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(9), + abap_group: "ty_s_time_entry_details", + technical_name: "work_date_utc", + csharp_name: "WorkDateUTC", + native_type: "string", + optional: false, + width: None, + carrier: LaneKind::U64, + }, + FieldDescriptor { + ordinal: Col(10), + abap_group: "ty_s_time_entry_details", + technical_name: "hours_logged", + csharp_name: "HoursLogged", + native_type: "catsquantity", + optional: false, + width: None, + carrier: LaneKind::I32, + }, + FieldDescriptor { + ordinal: Col(11), + abap_group: "ty_s_time_entry_details", + technical_name: "activity_type", + csharp_name: "ActivityType", + native_type: "lstar", + optional: false, + width: Some(6), + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(12), + abap_group: "ty_s_time_entry_details", + technical_name: "billing_indicator", + csharp_name: "BillingIndicator", + native_type: "string", + optional: false, + width: None, + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(13), + abap_group: "ty_s_time_sanitization", + technical_name: "sanitized_input_date", + csharp_name: "SanitizedInputDate", + native_type: "string", + optional: false, + width: None, + carrier: LaneKind::U64, + }, + FieldDescriptor { + ordinal: Col(14), + abap_group: "ty_s_time_sanitization", + technical_name: "original_input_date", + csharp_name: "OriginalInputDate", + native_type: "string", + optional: false, + width: None, + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(15), + abap_group: "ty_s_time_sanitization", + technical_name: "date_validation_status", + csharp_name: "DateValidationStatus", + native_type: "string", + optional: false, + width: None, + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(16), + abap_group: "ty_s_time_sanitization", + technical_name: "sanitization_reason", + csharp_name: "SanitizationReason", + native_type: "string", + optional: true, + width: None, + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(17), + abap_group: "ty_s_security_metadata", + technical_name: "encryption_indicator", + csharp_name: "EncryptionIndicator", + native_type: "abap_bool", + optional: false, + width: None, + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(18), + abap_group: "ty_s_security_metadata", + technical_name: "data_hash", + csharp_name: "DataHash", + native_type: "string", + optional: false, + width: None, + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(19), + abap_group: "ty_s_security_metadata", + technical_name: "compliance_label", + csharp_name: "ComplianceLabel", + native_type: "string", + optional: false, + width: None, + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(20), + abap_group: "ty_s_additional_metadata", + technical_name: "notes", + csharp_name: "Notes", + native_type: "string", + optional: true, + width: None, + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(21), + abap_group: "ty_s_additional_metadata", + technical_name: "approver_employee_num", + csharp_name: "ApproverEmployeeNumber", + native_type: "pernr_d", + optional: true, + width: Some(8), + carrier: LaneKind::U32, + }, + FieldDescriptor { + ordinal: Col(22), + abap_group: "ty_s_additional_metadata", + technical_name: "approval_timestamp_utc", + csharp_name: "ApprovalTimestampUTC", + native_type: "string", + optional: true, + width: None, + carrier: LaneKind::U64, + }, +]; +/// Class identity is supplied by the caller's registry; this kit reserves no +/// global class ID. DOLCE category is likewise caller metadata, not guessed. +#[derive(Debug)] +pub struct CatsSchema { + pub class: ClassId, + category: u8, + fields: Vec, +} +impl CatsSchema { + pub fn new(class: ClassId, category: u8) -> Self { + Self { + class, + category, + fields: FIELDS + .iter() + .map(|f| { + FieldRef::new( + format!("urn:simaf:cats:{}:{}", f.abap_group, f.technical_name), + f.technical_name, + ) + }) + .collect(), + } + } + /// Cold binding only. There is no name lookup in an execution program. + pub fn resolve(&self, name: &str) -> Option { + FIELDS + .iter() + .find(|f| f.technical_name == name || f.csharp_name == name) + .map(|f| f.ordinal) + } +} +impl ClassView for CatsSchema { + fn fields(&self, class: ClassId) -> &[FieldRef] { + if class == self.class { + &self.fields + } else { + &[] + } + } + fn template(&self, _: ClassId) -> DisplayTemplate { + DisplayTemplate::Detail + } + fn dolce_category_id(&self, _: ClassId) -> u8 { + self.category + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn source_order_and_aliases_are_one_basis() { + let schema = CatsSchema::new(42, 0); + let tsv = include_str!("../schema.tsv"); + for (i, (f, line)) in FIELDS.iter().zip(tsv.lines().skip(1)).enumerate() { + let cells: Vec<_> = line.split('\t').collect(); + assert_eq!(usize::from(f.ordinal.0), i); + assert_eq!(cells[0], i.to_string()); + assert_eq!(cells[2], f.technical_name); + assert_eq!(schema.resolve(cells[2]), schema.resolve(cells[4])); + assert_eq!(schema.fields(42)[i].label, f.technical_name); + } + assert_eq!(schema.fields(42).len(), FIELD_COUNT); + assert!(schema.fields(43).is_empty()); + assert_eq!(schema.resolve("invented"), None); + } +} diff --git a/crates/lance-graph-sap/tools/verify_sources.py b/crates/lance-graph-sap/tools/verify_sources.py new file mode 100644 index 000000000..471e06293 --- /dev/null +++ b/crates/lance-graph-sap/tools/verify_sources.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Verify the harvest against pinned external checkouts (never runtime input). + +Usage: python3 tools/verify_sources.py /path/containing/SIMAF/SIMAFPort/... +The sources stay in their original repositories; only descriptors are shipped. +""" +import csv +import hashlib +from pathlib import Path +import re +import sys + +HERE = Path(__file__).resolve().parents[1] +root = Path(sys.argv[1]) +for pin in csv.DictReader((HERE / 'sources.tsv').open(), delimiter='\t'): + data = (root / pin['repo'] / pin['path']).read_bytes() + actual = hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest() + assert actual == pin['blob'], (pin['path'], actual, pin['blob']) + +abap = (root / 'SIMAFPort/V4/complete/CLASSES/TimeTracking/ZCL_TIME_TRACKING_DTO_POC.clas.abap.txt').read_text() +cs = (root / 'SIMAFPort/src/UniversalDtoPoc/TimeTracking/TimeTrackingDtos.cs').read_text() +schema_cs = (root / 'SIMAF/Schema/UniversalDtoPoc.TimeTracking.txt').read_text() +leaves = [] +for group, body in re.findall(r'BEGIN OF (ty_s_\w+),(.*?)END OF \1', abap, re.S): + leaves += [(group, name, typ) for name, typ in re.findall(r'^\s*(\w+)\s+TYPE\s+(\w+)', body, re.M) if not typ.startswith('ty_s_')] +descriptors = list(csv.DictReader((HERE / 'schema.tsv').open(), delimiter='\t')) +assert len(leaves) == len(descriptors) == 23 +for ordinal, (leaf, field) in enumerate(zip(leaves, descriptors)): + assert int(field['ordinal']) == ordinal + assert leaf == (field['abap_group'], field['abap_name'], field['native_type']) + for source in [cs, schema_cs]: + typ = re.search(r'public (string\??|decimal|bool) ' + field['csharp_name'] + r' \{', source)[1] + assert typ.endswith('?') == (field['optional'] == 'true') + if field['width'] and field['native_type'] != 'pernr_d': + before = cs[:cs.index('public string' + ('?' if field['optional'] == 'true' else '') + ' ' + field['csharp_name'])] + assert re.findall(r'\[StringLength\((\d+)\)\]', before)[-1] == field['width'] + if field['native_type'] == 'pernr_d': + assert field['width'] == '8' and '^\\d{8}$' in cs + +# Source-derived disagreement is an oracle result, never a claimed parity pass. +processor = (root / 'SIMAFPort/V4/complete/CLASSES/TimeTracking/ZCL_TIME_DTO_PROCESSOR.clas.abap.txt').read_text() +hasher = (root / 'SMB-Core-Middleware/src/Core/Infrastructure/TimeTracking/TimeTrackingHasher.cs').read_text() +abap_order = re.findall(r'APPEND \|(\w+)=', processor) +cs_order = re.findall(r'sb.Append\((?:NormalizeField\()?entry\.(\w+)', hasher) +assert [n.lower() for n in abap_order] == [n.lower() for n in cs_order] +assert len(abap_order) == 16 +assert 'value.Trim().ToUpperInvariant()' in hasher +assert 'APPEND |EntryID=' in processor +print('PASS: 23 ABAP/C# field bindings; 16 hash ordinals; source blobs pinned.') +print('FALSIFIED: hash bytes differ (ABAP named values, SMB bare normalized values).') From 2366e29583624e325667f900723980e86730b39a Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Tue, 22 Sep 2026 06:03:42 +0200 Subject: [PATCH 03/14] feat(sap): bind CATS columns to borrowed ABI lanes --- crates/lance-graph-sap/src/bind.rs | 371 +++++++++++++++++++++ crates/lance-graph-sap/src/lib.rs | 1 + crates/lance-graph-sap/tests/binding.rs | 81 +++++ crates/lance-graph-sap/tests/common/mod.rs | 41 +++ 4 files changed, 494 insertions(+) create mode 100644 crates/lance-graph-sap/src/bind.rs create mode 100644 crates/lance-graph-sap/tests/binding.rs create mode 100644 crates/lance-graph-sap/tests/common/mod.rs diff --git a/crates/lance-graph-sap/src/bind.rs b/crates/lance-graph-sap/src/bind.rs new file mode 100644 index 000000000..e8debbe76 --- /dev/null +++ b/crates/lance-graph-sap/src/bind.rs @@ -0,0 +1,371 @@ +//! Cold, fallible ingestion. The output owns numeric columns; execution borrows +//! them as the existing LaneRef. Dictionaries are lossless edge metadata. +use crate::schema::{CatsSchema, FIELDS, FIELD_COUNT}; +use lance_graph_mask_risc::{words_for, LaneRef}; + +pub const EMPLOYEE: usize = 5; +pub const WORK_DATE: usize = 9; +pub const HOURS: usize = 10; +pub const ACTIVITY: usize = 11; +/// Derived date lens of the full UTC timestamp, bound once (YYYYMMDD). +pub const WORK_DAY: usize = FIELD_COUNT; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BindError(pub String); +impl std::fmt::Display for BindError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} +impl std::error::Error for BindError {} +fn error(s: &str) -> BindError { + BindError(s.into()) +} + +/// Owning storage only, not another ABI type system. Borrowed values use LaneRef. +#[derive(Debug)] +enum Column { + U32(Vec), + I32(Vec), + U64(Vec), +} +impl Column { + fn borrow(&self) -> LaneRef<'_> { + match self { + Self::U32(v) => LaneRef::U32(v), + Self::I32(v) => LaneRef::I32(v), + Self::U64(v) => LaneRef::U64(v), + } + } +} + +#[derive(Debug)] +pub struct CatsBatch { + pub schema: CatsSchema, + columns: Vec, + dictionaries: [Vec; FIELD_COUNT], + alpha: Vec, + len: usize, + scale: u32, +} + +impl CatsBatch { + /// Column-oriented edge input in the descriptor's stable order. No DTOs are + /// constructed. Null stays distinct from empty text and NUMC zero. ABAP + /// callers spell bool as X/space, C# callers as true/false. + pub fn bind( + schema: CatsSchema, + input: [&[Option<&str>]; FIELD_COUNT], + ) -> Result { + let len = input[0].len(); + if input.iter().any(|c| c.len() != len) { + return Err(error("ragged input")); + } + let mut dictionaries: [Vec; FIELD_COUNT] = std::array::from_fn(|_| Vec::new()); + let mut columns = Vec::with_capacity(FIELD_COUNT + 1); + let mut scale = 0; + for f in FIELDS { + let index = usize::from(f.ordinal.0); + let values = input[index]; + for value in values { + if value.is_none() && !f.optional { + return Err(error(f.technical_name)); + } + if !f.optional + && f.native_type != "abap_bool" + && value.is_some_and(|v| v.trim().is_empty()) + { + return Err(error("required field is blank")); + } + if let (Some(v), Some(width)) = (value, f.width) { + if v.encode_utf16().count() > width { + return Err(error("field width exceeded")); + } + } + } + let column = if index == HOURS { + let decimals: Vec<_> = values + .iter() + .map(|v| decimal(v.unwrap())) + .collect::>()?; + scale = decimals.iter().map(|(_, s)| *s).max().unwrap_or(0); + let mut lane = Vec::with_capacity(len); + for (coefficient, s) in decimals { + let scaled = coefficient + .checked_mul(10i128.pow(scale - s)) + .ok_or_else(|| error("decimal overflow"))?; + lane.push( + i32::try_from(scaled) + .map_err(|_| error("exact batch decimal scale exceeds I32 carrier"))?, + ); + } + Column::I32(lane) + } else if f.native_type == "pernr_d" { + Column::U32( + values + .iter() + .map(|v| v.map(numc).unwrap_or(Ok(u32::MAX))) + .collect::>()?, + ) + } else if f.native_type == "abap_bool" { + Column::U32( + values + .iter() + .map(|v| match v.unwrap() { + "X" | "true" => Ok(1), + " " | "" | "false" => Ok(0), + _ => Err(error("invalid ABAP_BOOL")), + }) + .collect::>()?, + ) + } else if matches!(f.carrier, lance_graph_mask_risc::LaneKind::U64) { + // Zero is a null sentinel; valid years start at 0001. + Column::U64( + values + .iter() + .map(|v| v.map(utc).unwrap_or(Ok(0))) + .collect::>()?, + ) + } else { + let dict = &mut dictionaries[index]; + let mut codes = Vec::with_capacity(len); + for value in values { + codes.push(if let Some(value) = value { + let position = if let Some(i) = dict.iter().position(|s| s == value) { + i + } else { + dict.push((*value).into()); + dict.len() - 1 + }; + u32::try_from(position + 1).map_err(|_| error("dictionary too large"))? + } else { + 0 + }); + } + Column::U32(codes) + }; + columns.push(column); + } + let Column::U64(dates) = &columns[WORK_DATE] else { + unreachable!() + }; + columns.push(Column::I32( + dates.iter().map(|v| (v / 1_000_000) as i32).collect(), + )); + let mut alpha = vec![u64::MAX; words_for(len)]; + if len % 64 != 0 { + *alpha.last_mut().unwrap() = (1u64 << (len % 64)) - 1; + } + Ok(Self { + schema, + columns, + dictionaries, + alpha, + len, + scale, + }) + } + pub fn len(&self) -> usize { + self.len + } + pub fn is_empty(&self) -> bool { + self.len == 0 + } + pub fn scale(&self) -> u32 { + self.scale + } + pub fn alpha(&self) -> &[u64] { + &self.alpha + } + pub fn lanes(&self) -> [LaneRef<'_>; FIELD_COUNT + 1] { + std::array::from_fn(|i| self.columns[i].borrow()) + } + /// Group cardinality includes reserved NULL code 0. Required ActivityType + /// never uses it; an empty group remains an explicit zero sum. + pub fn activity_groups(&self) -> u32 { + (self.dictionaries[ACTIVITY].len() + 1) as u32 + } + pub fn activity_label(&self, code: u32) -> Option<&str> { + code.checked_sub(1) + .and_then(|i| self.dictionaries[ACTIVITY].get(i as usize)) + .map(String::as_str) + } + /// Explicit terminal adapter only; never invoked by a fold. + pub fn edge_value(&self, ordinal: usize, row: usize) -> Result, BindError> { + let field = FIELDS + .get(ordinal) + .ok_or_else(|| error("invalid field ordinal"))?; + if row >= self.len { + return Err(error("invalid sink row")); + } + Ok(match &self.columns[ordinal] { + Column::I32(v) => Some(format_decimal(i64::from(v[row]), self.scale)), + Column::U64(v) => { + if v[row] == 0 { + None + } else { + Some(format_utc(v[row])) + } + } + Column::U32(v) if field.native_type == "pernr_d" => { + if v[row] == u32::MAX { + None + } else { + Some(format!("{:08}", v[row])) + } + } + Column::U32(v) if field.native_type == "abap_bool" => { + Some(if v[row] == 1 { "true" } else { "false" }.into()) + } + Column::U32(v) => v[row] + .checked_sub(1) + .map(|i| self.dictionaries[ordinal][i as usize].clone()), + }) + } +} + +pub fn numc(s: &str) -> Result { + if s.len() != 8 || !s.bytes().all(|b| b.is_ascii_digit()) { + return Err(error("PERNR requires eight ASCII digits")); + } + s.parse().map_err(|_| error("invalid PERNR")) +} + +/// Exact positive C# decimal subset admitted by its [Range(0.01, 24.00)]. +/// No native CATS scale is asserted. Up to 28 fractional digits are parsed; +/// binding rejects a batch whose exact common scale does not fit its ABI lane. +fn decimal(s: &str) -> Result<(i128, u32), BindError> { + let (whole, fraction) = s.split_once('.').unwrap_or((s, "")); + if whole.is_empty() + || !whole.bytes().all(|b| b.is_ascii_digit()) + || !fraction.bytes().all(|b| b.is_ascii_digit()) + || fraction.len() > 28 + { + return Err(error("invalid decimal")); + } + let fraction = fraction.trim_end_matches('0'); + let scale = fraction.len() as u32; + let coefficient: i128 = format!("{whole}{fraction}") + .parse() + .map_err(|_| error("decimal overflow"))?; + let power = 10i128.pow(scale); + if coefficient > 24 * power || coefficient.checked_mul(100).is_none_or(|v| v < power) { + return Err(error("hours outside C# range")); + } + Ok((coefficient, scale)) +} + +pub fn format_decimal(value: i64, scale: u32) -> String { + if scale == 0 { + return value.to_string(); + } + let power = 10i64.pow(scale); + format!( + "{}{}.{:0width$}", + if value < 0 { "-" } else { "" }, + value.unsigned_abs() / power as u64, + value.unsigned_abs() % power as u64, + width = scale as usize + ) +} + +/// Lossless sortable YYYYMMDDhhmmss carrier for the corpus's strict UTC text. +/// Calendar validity is checked; time-of-day is never discarded for hashing. +pub fn utc(s: &str) -> Result { + let b = s.as_bytes(); + if b.len() != 20 + || b[4] != b'-' + || b[7] != b'-' + || b[10] != b'T' + || b[13] != b':' + || b[16] != b':' + || b[19] != b'Z' + { + return Err(error("strict UTC timestamp required")); + } + let mut digits = String::with_capacity(14); + for (i, &c) in b.iter().enumerate() { + if [4, 7, 10, 13, 16, 19].contains(&i) { + continue; + } + if !c.is_ascii_digit() { + return Err(error("invalid UTC digits")); + } + digits.push(c as char); + } + let n: u64 = digits.parse().map_err(|_| error("invalid UTC"))?; + let year = n / 10_000_000_000; + let month = (n / 100_000_000 % 100) as usize; + let day = n / 1_000_000 % 100; + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let days = [ + 31, + if leap { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + if year == 0 + || !(1..=12).contains(&month) + || day == 0 + || day > days[month - 1] + || n / 10_000 % 100 > 23 + || n / 100 % 100 > 59 + || n % 100 > 59 + { + return Err(error("invalid calendar timestamp")); + } + Ok(n) +} +fn format_utc(n: u64) -> String { + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + n / 10_000_000_000, + n / 100_000_000 % 100, + n / 1_000_000 % 100, + n / 10_000 % 100, + n / 100 % 100, + n % 100 + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn lossless_adapters_reject_lossy_inputs() { + assert_eq!(numc("00000042"), Ok(42)); + assert!(numc("42").is_err()); + assert!(numc("12345678").is_err()); + assert_eq!(decimal("8.500"), Ok((85, 1))); + assert_eq!( + decimal("0.01234567890123456789"), + Ok((1234567890123456789, 20)) + ); + for bad in ["0", "24.01", "NaN", "-1", "1.2.3"] { + assert!(decimal(bad).is_err()); + } + for good in [ + "2024-02-29T23:59:59Z", + "0001-01-01T00:00:00Z", + "9999-12-31T23:59:59Z", + ] { + assert_eq!(format_utc(utc(good).unwrap()), good); + } + for bad in [ + "2025-02-29T00:00:00Z", + "2024-01-01T24:00:00Z", + "2024-00-01T00:00:00Z", + "2024-01-01T00:00:00+01:00", + ] { + assert!(utc(bad).is_err()); + } + } +} diff --git a/crates/lance-graph-sap/src/lib.rs b/crates/lance-graph-sap/src/lib.rs index 1157e247a..94788214f 100644 --- a/crates/lance-graph-sap/src/lib.rs +++ b/crates/lance-graph-sap/src/lib.rs @@ -1,3 +1,4 @@ //! CATS boundary adapters. Names and source types live in the cold schema; //! execution borrows the existing mask-RISC ABI. No SAP evaluator. +pub mod bind; pub mod schema; diff --git a/crates/lance-graph-sap/tests/binding.rs b/crates/lance-graph-sap/tests/binding.rs new file mode 100644 index 000000000..18b336b06 --- /dev/null +++ b/crates/lance-graph-sap/tests/binding.rs @@ -0,0 +1,81 @@ +mod common; +use common::*; +use lance_graph_mask_risc::LaneRef; +use lance_graph_sap::{bind::*, schema::CatsSchema}; + +#[test] +fn columns_borrow_without_copy_and_preserve_null_zero_empty_and_time() { + let mut input = fixture(65); + input[6][0] = None; + input[6][1] = Some(""); + input[21][0] = Some("00000000"); + input[9][0] = Some("2026-09-01T23:59:59Z"); + input[10][1] = Some("0.125"); + let batch = bind(&input); + assert_eq!(batch.scale(), 3); + let first = batch.lanes(); + let second = batch.lanes(); + let (LaneRef::I32(a), LaneRef::I32(b)) = (first[HOURS], second[HOURS]) else { + panic!() + }; + assert_eq!(a.as_ptr(), b.as_ptr()); + assert_eq!(a[0], 8500); + assert_eq!(a[1], 125); + assert_eq!(batch.alpha(), &[u64::MAX, 1]); + assert_eq!(batch.edge_value(6, 0).unwrap(), None); + assert_eq!(batch.edge_value(6, 1).unwrap().as_deref(), Some("")); + assert_eq!( + batch.edge_value(21, 0).unwrap().as_deref(), + Some("00000000") + ); + assert_eq!(batch.edge_value(21, 1).unwrap(), None); + assert_eq!( + batch.edge_value(9, 0).unwrap().as_deref(), + Some("2026-09-01T23:59:59Z") + ); + assert_eq!(batch.edge_value(10, 1).unwrap().as_deref(), Some("0.125")); +} + +#[test] +fn abap_and_csharp_boolean_edges_bind_to_identical_lanes() { + let mut cs = fixture(2); + cs[17][1] = Some("false"); + let mut abap = cs.clone(); + abap[17] = vec![Some("X"), Some(" ")]; + let (cs, abap) = (bind(&cs), bind(&abap)); + for field in 0..23 { + for row in 0..2 { + assert_eq!(cs.edge_value(field, row), abap.edge_value(field, row)); + } + } +} + +#[test] +fn invalid_inputs_do_not_enter_the_abi() { + for (field, value) in [ + (5, "42"), + (6, "12345678901"), + (10, "0.012345678901"), + (9, "2025-02-29T00:00:00Z"), + (11, ""), + ] { + let mut input = fixture(1); + input[field][0] = Some(value); + assert!( + CatsBatch::bind( + CatsSchema::new(42, 0), + std::array::from_fn(|i| input[i].as_slice()) + ) + .is_err(), + "{field} {value}" + ); + } + let mut input = fixture(1); + input[3].clear(); + assert!(CatsBatch::bind( + CatsSchema::new(42, 0), + std::array::from_fn(|i| input[i].as_slice()) + ) + .is_err()); + assert!(bind(&fixture(0)).is_empty()); +} diff --git a/crates/lance-graph-sap/tests/common/mod.rs b/crates/lance-graph-sap/tests/common/mod.rs new file mode 100644 index 000000000..b588f88c7 --- /dev/null +++ b/crates/lance-graph-sap/tests/common/mod.rs @@ -0,0 +1,41 @@ +use lance_graph_sap::{ + bind::CatsBatch, + schema::{CatsSchema, FIELD_COUNT}, +}; + +pub fn fixture(n: usize) -> [Vec>; FIELD_COUNT] { + let base = [ + Some("entry-1"), + Some("SAP"), + Some("BillableHours"), + Some("100"), + Some("2026-09-01T12:34:56Z"), + Some("00000042"), + Some("0000000123"), + Some("WBS-1"), + Some("000000000123"), + Some("2026-09-01T00:00:00Z"), + Some("8.50"), + Some("DEV"), + Some("Billable"), + Some("2026-09-01T00:00:00Z"), + Some("2026-09-01T02:00:00+02:00"), + Some("Valid"), + None, + Some("true"), + Some("fixture-hash"), + Some("GDPR"), + Some("fixture note"), + None, + None, + ]; + std::array::from_fn(|i| vec![base[i]; n]) +} + +pub fn bind(input: &[Vec>; FIELD_COUNT]) -> CatsBatch { + CatsBatch::bind( + CatsSchema::new(42, 0), + std::array::from_fn(|i| input[i].as_slice()), + ) + .unwrap() +} From 26cb05a9f0ca95660e978eff92ace88f22dec38f Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Tue, 22 Sep 2026 06:03:43 +0200 Subject: [PATCH 04/14] feat(sap): fold bound CATS lanes through Quack without allocation --- crates/lance-graph-sap/src/lib.rs | 1 + crates/lance-graph-sap/src/query.rs | 100 +++++++++++++++++++++++ crates/lance-graph-sap/tests/fold.rs | 61 ++++++++++++++ crates/lance-graph-sap/tests/no_alloc.rs | 44 ++++++++++ 4 files changed, 206 insertions(+) create mode 100644 crates/lance-graph-sap/src/query.rs create mode 100644 crates/lance-graph-sap/tests/fold.rs create mode 100644 crates/lance-graph-sap/tests/no_alloc.rs diff --git a/crates/lance-graph-sap/src/lib.rs b/crates/lance-graph-sap/src/lib.rs index 94788214f..a36d99f36 100644 --- a/crates/lance-graph-sap/src/lib.rs +++ b/crates/lance-graph-sap/src/lib.rs @@ -1,4 +1,5 @@ //! CATS boundary adapters. Names and source types live in the cold schema; //! execution borrows the existing mask-RISC ABI. No SAP evaluator. pub mod bind; +pub mod query; pub mod schema; diff --git a/crates/lance-graph-sap/src/query.rs b/crates/lance-graph-sap/src/query.rs new file mode 100644 index 000000000..711fbebef --- /dev/null +++ b/crates/lance-graph-sap/src/query.rs @@ -0,0 +1,100 @@ +//! Consumer orchestration only. Quack lowers; mask-RISC executes. This module +//! never evaluates a predicate, visits source rows, or looks up a label. +use crate::bind::{numc, utc, BindError, CatsBatch, ACTIVITY, EMPLOYEE, HOURS, WORK_DAY}; +use lance_graph_mask_risc::{execute, ExecError, Operand, Planes, Scratch, Value}; +use lance_graph_quack::{lower_group_by, Agg, Cmp, Col, Filter, GroupBy, GroupPlan}; + +/// Bound to one immutable batch, so dictionary IDs cannot cross populations. +pub struct CatsQuery<'a> { + batch: &'a CatsBatch, + plan: GroupPlan, + filter_scratch: Scratch<'static>, + group_scratch: Scratch<'static>, +} +impl<'a> CatsQuery<'a> { + /// Cold phase: employee and inclusive UTC calendar dates bind once. + /// ApprovalStatus is absent from the ABAP/SIMAFPort schema and is not + /// inferred from an approver or a timestamp. + pub fn prepare( + batch: &'a CatsBatch, + employee: &str, + from: &str, + to: &str, + ) -> Result { + let employee = numc(employee)?; + let from = utc(&format!("{from}T00:00:00Z"))? / 1_000_000; + let to = utc(&format!("{to}T00:00:00Z"))? / 1_000_000; + if from > to { + return Err(BindError("reversed date range".into())); + } + let plan = lower_group_by( + &GroupBy { + filter: Filter::and([ + Filter::cmp(Col(EMPLOYEE as u16), Cmp::EqU32(employee)), + Filter::cmp(Col(WORK_DAY as u16), Cmp::GeI32(from as i32)), + Filter::cmp(Col(WORK_DAY as u16), Cmp::LeI32(to as i32)), + ]), + key: Col(ACTIVITY as u16), + groups: batch.activity_groups(), + agg: Agg::SumI32(Col(HOURS as u16)), + }, + 0, + ) + .map_err(|e| BindError(format!("Quack lowering: {e:?}")))?; + let filter_scratch = Scratch::for_program(&plan.filter, batch.len()) + .map_err(|e| BindError(format!("scratch: {e:?}")))?; + let group_scratch = Scratch::for_program(&plan.groups[0], batch.len()) + .map_err(|e| BindError(format!("scratch: {e:?}")))?; + Ok(Self { + batch, + plan, + filter_scratch, + group_scratch, + }) + } + pub fn groups(&self) -> usize { + self.plan.groups.len() + } + pub fn plan(&self) -> &GroupPlan { + &self.plan + } + /// Hot phase: no allocation, text, reflection, DTOs or intermediate rows. + /// Sums are in the batch's explicit decimal scale. Returns the BORROWED + /// survivor mask for an optional terminal BAPI sink; no mask copy. + /// Quack currently runs one kept filter plus one fold per dictionary key, + /// not a claimed single-pass grouped aggregate. + pub fn execute_into(&mut self, sums: &mut [i64]) -> Result<&[u64], ExecError> { + if sums.len() != self.groups() { + return Err(ExecError::LenMismatch { + what: "group sums", + expected: self.groups(), + found: sums.len(), + }); + } + let lanes = self.batch.lanes(); + let input = Planes { + n_rows: self.batch.len(), + masks: &[], + lanes: &lanes, + }; + let kept = execute(&self.plan.filter, &input, &mut self.filter_scratch, None)?; + let Value::Mask(Operand::Scratch(slot)) = kept else { + unreachable!("lowered comparison filter") + }; + let mask = self.filter_scratch.slot(slot).expect("validated scratch"); + let masks = [mask]; + let grouped = Planes { + n_rows: self.batch.len(), + masks: &masks, + lanes: &lanes, + }; + for (program, sum) in self.plan.groups.iter().zip(sums) { + let Value::SumI64(value) = execute(program, &grouped, &mut self.group_scratch, None)? + else { + unreachable!("SumI32 terminal") + }; + *sum = value; + } + Ok(mask) + } +} diff --git a/crates/lance-graph-sap/tests/fold.rs b/crates/lance-graph-sap/tests/fold.rs new file mode 100644 index 000000000..e5d9becff --- /dev/null +++ b/crates/lance-graph-sap/tests/fold.rs @@ -0,0 +1,61 @@ +mod common; +use common::*; +use lance_graph_mask_risc::{reference_execute, Planes, Value}; +use lance_graph_sap::query::CatsQuery; + +#[test] +fn employee_date_activity_sum_matches_independent_oracle_across_word_tails() { + for n in [0, 1, 63, 64, 65, 131, 4097] { + let mut input = fixture(n); + let mut expected_dev = 0; + let mut expected_ops = 0; + for i in 0..n { + input[5][i] = Some(if i % 3 == 0 { "00000007" } else { "00000042" }); + input[9][i] = Some(if i % 5 == 0 { + "2026-08-31T23:59:59Z" + } else { + "2026-09-01T12:34:56Z" + }); + input[11][i] = Some(if i % 2 == 0 { "DEV" } else { "OPS" }); + input[10][i] = Some(if i % 2 == 0 { "8.50" } else { "0.125" }); + if i % 3 != 0 && i % 5 != 0 { + if i % 2 == 0 { + expected_dev += 8500; + } else { + expected_ops += 125; + } + } + } + let batch = bind(&input); + let mut query = CatsQuery::prepare(&batch, "00000042", "2026-09-01", "2026-09-30").unwrap(); + let mut sums = vec![0; query.groups()]; + let mask = query.execute_into(&mut sums).unwrap().to_vec(); // TEST-only copy for oracle + for (i, &sum) in sums.iter().enumerate() { + let expected = match batch.activity_label(i as u32) { + Some("DEV") => expected_dev, + Some("OPS") => expected_ops, + None => 0, + _ => unreachable!(), + }; + assert_eq!(sum, expected, "n={n}, group={i}"); + } + let lanes = batch.lanes(); + let masks = [mask.as_slice()]; + let planes = Planes { + n_rows: n, + masks: &masks, + lanes: &lanes, + }; + for (program, sum) in query.plan().groups.iter().zip(sums) { + assert_eq!( + reference_execute(program, &planes, None).unwrap(), + Value::SumI64(sum) + ); + } + let mut absent = + CatsQuery::prepare(&batch, "99999999", "2026-09-01", "2026-09-30").unwrap(); + let mut sums = vec![1; absent.groups()]; + absent.execute_into(&mut sums).unwrap(); + assert!(sums.iter().all(|v| *v == 0)); + } +} diff --git a/crates/lance-graph-sap/tests/no_alloc.rs b/crates/lance-graph-sap/tests/no_alloc.rs new file mode 100644 index 000000000..39d0ab0f0 --- /dev/null +++ b/crates/lance-graph-sap/tests/no_alloc.rs @@ -0,0 +1,44 @@ +mod common; +use lance_graph_sap::query::CatsQuery; +use std::{ + alloc::{GlobalAlloc, Layout, System}, + cell::Cell, +}; +thread_local! { static ACTIVE: Cell = const { Cell::new(false) }; static COUNT: Cell = const { Cell::new(0) }; } +struct Counter; +unsafe impl GlobalAlloc for Counter { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ACTIVE.with(|a| { + if a.get() { + COUNT.with(|c| c.set(c.get() + 1)); + } + }); + System.alloc(layout) + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + System.dealloc(ptr, layout) + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 { + ACTIVE.with(|a| { + if a.get() { + COUNT.with(|c| c.set(c.get() + 1)); + } + }); + System.realloc(ptr, layout, size) + } +} +#[global_allocator] +static ALLOCATOR: Counter = Counter; +#[test] +fn the_entire_bound_query_allocates_zero_bytes() { + let input = common::fixture(4097); + let batch = common::bind(&input); + let mut query = CatsQuery::prepare(&batch, "00000042", "2026-09-01", "2026-09-30").unwrap(); + let mut sums = vec![0; query.groups()]; + ACTIVE.with(|a| a.set(true)); + let result = query.execute_into(&mut sums).map(|mask| mask.len()); + ACTIVE.with(|a| a.set(false)); + assert!(result.is_ok()); + assert_eq!(COUNT.with(Cell::get), 0); + assert_eq!(sums[1], 4097 * 85); +} From 27095d22d39614b61d7ef6677e3b7a3fce02ac2f Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Tue, 22 Sep 2026 06:03:44 +0200 Subject: [PATCH 05/14] feat(sap): emit terminal CATS adapters and differential hash oracles W4: Original pinned C# sources compiled with .NET 8.0.425: all 23 fixture values, SMB ordered projection and HMAC match Rust; Python HMAC also matches. ABAP is source-inspected, not runtime-executed. Preserve explicit incompatible ABAP/SMB hash profiles; refuse unsupported rounding, Unicode normalization, and short notes(50) BAPI fixtures. BAPI uses selected original assignments, never posts an activity aggregate under invented dimensions. --- crates/lance-graph-sap/.gitignore | 3 + crates/lance-graph-sap/Cargo.toml | 3 +- crates/lance-graph-sap/examples/oracle.rs | 21 ++ crates/lance-graph-sap/fixtures/cats.txt | 23 +++ crates/lance-graph-sap/src/bind.rs | 6 +- crates/lance-graph-sap/src/edge.rs | 182 ++++++++++++++++++ crates/lance-graph-sap/src/lib.rs | 1 + crates/lance-graph-sap/tests/common/mod.rs | 30 +-- crates/lance-graph-sap/tests/edges.rs | 83 ++++++++ crates/lance-graph-sap/tests/fold.rs | 1 + .../tools/csharp/Oracle.csproj | 12 ++ .../lance-graph-sap/tools/csharp/Program.cs | 32 +++ .../lance-graph-sap/tools/verify_oracles.py | 25 +++ 13 files changed, 393 insertions(+), 29 deletions(-) create mode 100644 crates/lance-graph-sap/.gitignore create mode 100644 crates/lance-graph-sap/examples/oracle.rs create mode 100644 crates/lance-graph-sap/fixtures/cats.txt create mode 100644 crates/lance-graph-sap/src/edge.rs create mode 100644 crates/lance-graph-sap/tests/edges.rs create mode 100644 crates/lance-graph-sap/tools/csharp/Oracle.csproj create mode 100644 crates/lance-graph-sap/tools/csharp/Program.cs create mode 100644 crates/lance-graph-sap/tools/verify_oracles.py diff --git a/crates/lance-graph-sap/.gitignore b/crates/lance-graph-sap/.gitignore new file mode 100644 index 000000000..4d1390969 --- /dev/null +++ b/crates/lance-graph-sap/.gitignore @@ -0,0 +1,3 @@ +/target/ +/tools/csharp/bin/ +/tools/csharp/obj/ diff --git a/crates/lance-graph-sap/Cargo.toml b/crates/lance-graph-sap/Cargo.toml index 59025a10e..c7a5075be 100644 --- a/crates/lance-graph-sap/Cargo.toml +++ b/crates/lance-graph-sap/Cargo.toml @@ -11,4 +11,5 @@ description = "Source-pinned CATS boundary adapters over ClassView and Quack" lance-graph-contract = { path = "../lance-graph-contract" } lance-graph-mask-risc = { path = "../lance-graph-mask-risc" } lance-graph-quack = { path = "../lance-graph-quack" } - +hmac = "0.12" +sha2 = "0.10" diff --git a/crates/lance-graph-sap/examples/oracle.rs b/crates/lance-graph-sap/examples/oracle.rs new file mode 100644 index 000000000..41021dc45 --- /dev/null +++ b/crates/lance-graph-sap/examples/oracle.rs @@ -0,0 +1,21 @@ +use lance_graph_sap::{bind::CatsBatch, edge::*, schema::CatsSchema}; +fn main() { + let values: Vec<_> = include_str!("../fixtures/cats.txt") + .lines() + .map(|v| if v == "\\N" { None } else { Some(v) }) + .collect(); + let columns: [_; 23] = std::array::from_fn(|i| [values[i]]); + let batch = CatsBatch::bind( + CatsSchema::new(42, 0), + std::array::from_fn(|i| columns[i].as_slice()), + ) + .unwrap(); + for value in csharp_fields(&batch, 0).unwrap() { + println!("{}", value.as_deref().unwrap_or("\\N")); + } + let projection = ordered_hash_projection(&batch, 0, HashProfile::SmbMiddleware).unwrap(); + println!( + "{projection}\n{}", + hash_projection(&projection, b"fixture-key") + ); +} diff --git a/crates/lance-graph-sap/fixtures/cats.txt b/crates/lance-graph-sap/fixtures/cats.txt new file mode 100644 index 000000000..7c103637b --- /dev/null +++ b/crates/lance-graph-sap/fixtures/cats.txt @@ -0,0 +1,23 @@ +entry-1 +SAP +BillableHours +100 +2026-09-01T12:34:56Z +00000042 +0000000123 +WBS-1 +000000000123 +2026-09-01T00:00:00Z +8.50 +DEV +Billable +2026-09-01T00:00:00Z +2026-09-01T02:00:00+02:00 +Valid +\N +true +fixture-hash +GDPR +fixture note +\N +\N diff --git a/crates/lance-graph-sap/src/bind.rs b/crates/lance-graph-sap/src/bind.rs index e8debbe76..de20004c8 100644 --- a/crates/lance-graph-sap/src/bind.rs +++ b/crates/lance-graph-sap/src/bind.rs @@ -153,7 +153,7 @@ impl CatsBatch { dates.iter().map(|v| (v / 1_000_000) as i32).collect(), )); let mut alpha = vec![u64::MAX; words_for(len)]; - if len % 64 != 0 { + if !len.is_multiple_of(64) { *alpha.last_mut().unwrap() = (1u64 << (len % 64)) - 1; } Ok(Self { @@ -255,7 +255,7 @@ fn decimal(s: &str) -> Result<(i128, u32), BindError> { Ok((coefficient, scale)) } -pub fn format_decimal(value: i64, scale: u32) -> String { +pub(crate) fn format_decimal(value: i64, scale: u32) -> String { if scale == 0 { return value.to_string(); } @@ -297,7 +297,7 @@ pub fn utc(s: &str) -> Result { let year = n / 10_000_000_000; let month = (n / 100_000_000 % 100) as usize; let day = n / 1_000_000 % 100; - let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)); let days = [ 31, if leap { 29 } else { 28 }, diff --git a/crates/lance-graph-sap/src/edge.rs b/crates/lance-graph-sap/src/edge.rs new file mode 100644 index 000000000..4fe4de523 --- /dev/null +++ b/crates/lance-graph-sap/src/edge.rs @@ -0,0 +1,182 @@ +//! Terminal sinks only. Source hash profiles deliberately do not agree. +use crate::{ + bind::{format_decimal, BindError, CatsBatch}, + schema::{FIELDS, FIELD_COUNT}, +}; +use hmac::{Hmac, Mac}; +use sha2::Sha512; + +/// Pinned ABAP prepare_hash_data order, also used (without names) by SMB. +pub const HASH_ORDINALS: [usize; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 20, 21, 22]; + +#[derive(Debug, Clone, Copy)] +pub enum HashProfile { + /// Named, case-preserving fields. Decimal formatting depends on the SAP + /// user's notation: the caller must provide the observed separator. + SimafPortAbap { decimal_separator: char }, + /// Bare, trimmed, uppercase fields and an invariant two-place decimal. + SmbMiddleware, +} + +/// The source code only proves ASCII normalization and exact cents here. +/// Refuse higher-precision rounding and Unicode case conversion rather than +/// claim cross-runtime equivalence without running those runtimes. +pub fn ordered_hash_projection( + batch: &CatsBatch, + row: usize, + profile: HashProfile, +) -> Result { + let mut parts = Vec::with_capacity(HASH_ORDINALS.len()); + for ordinal in HASH_ORDINALS { + let mut text = batch.edge_value(ordinal, row)?.unwrap_or_default(); + if !text.is_ascii() { + return Err(BindError("hash oracle supports ASCII only".into())); + } + if ordinal == 10 { + text = exact_cents(&text)?; + } + match profile { + HashProfile::SmbMiddleware => { + if ordinal == 3 { + let tenant: i32 = text + .parse() + .map_err(|_| BindError("SMB TenantId requires Int32".into()))?; + if tenant.to_string() != text { + return Err(BindError( + "SMB TenantId would lose source formatting".into(), + )); + } + } + text = text.trim().to_ascii_uppercase(); + } + HashProfile::SimafPortAbap { decimal_separator } => { + if !['.', ','].contains(&decimal_separator) { + return Err(BindError("unsupported ABAP decimal notation".into())); + } + if text != text.trim() { + return Err(BindError( + "ABAP whitespace formatting requires a runtime oracle".into(), + )); + } + if ordinal == 10 { + text = text.replace('.', &decimal_separator.to_string()); + } + text = format!("{}={text}", FIELDS[ordinal].csharp_name); + } + } + parts.push(text); + } + Ok(parts.join("|")) +} + +fn exact_cents(text: &str) -> Result { + let (whole, fraction) = text.split_once('.').unwrap_or((text, "")); + let fraction = fraction.trim_end_matches('0'); + if fraction.len() > 2 { + return Err(BindError( + "hash rounding needs an explicit runtime oracle".into(), + )); + } + Ok(format!("{whole}.{fraction:0<2}")) +} + +/// `key` is actual key bytes; ABAP UTF-8 and SMB base64 constructor decoding +/// are separate caller-side key adapters, never query inputs. +pub fn hash_projection(projection: &str, key: &[u8]) -> String { + let mut mac = Hmac::::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(projection.as_bytes()); + mac.finalize() + .into_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// A requested DTO/JSON boundary can consume canonical values in C# field order. +/// This is the only whole-entry materialization; query execution never calls it. +pub fn csharp_fields( + batch: &CatsBatch, + row: usize, +) -> Result<[Option; FIELD_COUNT], BindError> { + let mut result = std::array::from_fn(|_| None); + for (ordinal, value) in result.iter_mut().enumerate() { + *value = batch.edge_value(ordinal, row)?; + } + Ok(result) +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ActivityTotal { + pub activity_type: String, + /// Exact decimal text for a C# decimal, JSON decimal or UI sink. + pub hours: String, +} +pub fn activity_totals(batch: &CatsBatch, sums: &[i64]) -> Result, BindError> { + if sums.len() != batch.activity_groups() as usize { + return Err(BindError("wrong group result length".into())); + } + let mut result = Vec::new(); + for (code, &sum) in sums.iter().enumerate() { + if sum == 0 { + continue; + } // Hours are strictly positive: zero means no surviving entry. + let label = batch + .activity_label(code as u32) + .ok_or_else(|| BindError("invalid activity code".into()))?; + result.push(ActivityTotal { + activity_type: label.into(), + hours: format_decimal(sum, batch.scale()), + }); + } + Ok(result) +} + +/// Exactly the eight assignments in the pinned ABAP processor's BAPI mapping. +/// No connectivity or SAP master-data validation is implied. +#[derive(Debug, PartialEq, Eq)] +pub struct BapiCatsInsert { + pub employeenumber: String, + pub workdate: String, + pub hours: String, + pub activitytype: String, + pub wbs_element: String, + pub orderid: String, + pub cust_spec_pr: String, + pub shorttext: String, +} +pub const BAPI_FUNCTION: &str = "BAPI_CATIMESHEETMGR_INSERT"; + +/// Explicit row sink. The substrate's sole materializer is used only here. +/// Aggregated hours MUST NOT be posted under an invented project/date. +pub fn bapi_sink(batch: &CatsBatch, kept: &[u64]) -> Result, BindError> { + if kept.len() != batch.alpha().len() || kept.iter().zip(batch.alpha()).any(|(a, b)| a & !b != 0) + { + return Err(BindError("invalid terminal selection mask".into())); + } + let mut result = Vec::new(); + for row in lance_graph_mask_risc::materialize_rows(kept, batch.len()) { + let field = + |i| -> Result { Ok(batch.edge_value(i, row)?.unwrap_or_default()) }; + let notes = field(20)?; + // ABAP source uses notes(50), not a documented safe truncation helper. + // Restrict the fixture to at least 50 ASCII characters; do not silently + // repair or claim parity for short strings / UTF-16 substring behavior. + if !notes.is_ascii() || notes.len() < 50 { + return Err(BindError( + "BAPI notes(50) requires a 50-byte ASCII oracle fixture".into(), + )); + } + let date = field(9)?; + result.push(BapiCatsInsert { + employeenumber: field(5)?, + workdate: date[..10].replace('-', ""), + hours: field(10)?, + activitytype: field(11)?, + wbs_element: field(7)?, + orderid: field(8)?, + cust_spec_pr: field(6)?, + shorttext: notes[..50].into(), + }); + } + Ok(result) +} diff --git a/crates/lance-graph-sap/src/lib.rs b/crates/lance-graph-sap/src/lib.rs index a36d99f36..dbe6b9768 100644 --- a/crates/lance-graph-sap/src/lib.rs +++ b/crates/lance-graph-sap/src/lib.rs @@ -1,5 +1,6 @@ //! CATS boundary adapters. Names and source types live in the cold schema; //! execution borrows the existing mask-RISC ABI. No SAP evaluator. pub mod bind; +pub mod edge; pub mod query; pub mod schema; diff --git a/crates/lance-graph-sap/tests/common/mod.rs b/crates/lance-graph-sap/tests/common/mod.rs index b588f88c7..0c6b3cc4d 100644 --- a/crates/lance-graph-sap/tests/common/mod.rs +++ b/crates/lance-graph-sap/tests/common/mod.rs @@ -4,31 +4,11 @@ use lance_graph_sap::{ }; pub fn fixture(n: usize) -> [Vec>; FIELD_COUNT] { - let base = [ - Some("entry-1"), - Some("SAP"), - Some("BillableHours"), - Some("100"), - Some("2026-09-01T12:34:56Z"), - Some("00000042"), - Some("0000000123"), - Some("WBS-1"), - Some("000000000123"), - Some("2026-09-01T00:00:00Z"), - Some("8.50"), - Some("DEV"), - Some("Billable"), - Some("2026-09-01T00:00:00Z"), - Some("2026-09-01T02:00:00+02:00"), - Some("Valid"), - None, - Some("true"), - Some("fixture-hash"), - Some("GDPR"), - Some("fixture note"), - None, - None, - ]; + let values: Vec<_> = include_str!("../../fixtures/cats.txt") + .lines() + .map(|v| if v == "\\N" { None } else { Some(v) }) + .collect(); + let base: [Option<&str>; FIELD_COUNT] = values.try_into().unwrap(); std::array::from_fn(|i| vec![base[i]; n]) } diff --git a/crates/lance-graph-sap/tests/edges.rs b/crates/lance-graph-sap/tests/edges.rs new file mode 100644 index 000000000..797058b47 --- /dev/null +++ b/crates/lance-graph-sap/tests/edges.rs @@ -0,0 +1,83 @@ +mod common; +use common::*; +use lance_graph_sap::{edge::*, query::CatsQuery}; + +#[test] +fn same_carrier_emits_totals_and_only_selected_original_bapi_assignments() { + let mut input = fixture(3); + input[20] = vec![Some("123456789012345678901234567890123456789012345678901234567890"); 3]; + input[5][1] = Some("00000007"); + input[7][2] = Some("WBS-2"); + let batch = bind(&input); + let mut query = CatsQuery::prepare(&batch, "00000042", "2026-09-01", "2026-09-30").unwrap(); + let mut sums = vec![0; query.groups()]; + let kept = query.execute_into(&mut sums).unwrap(); + let totals = activity_totals(&batch, &sums).unwrap(); + assert_eq!( + totals, + vec![ActivityTotal { + activity_type: "DEV".into(), + hours: "17.0".into() + }] + ); + let posted = bapi_sink(&batch, kept).unwrap(); + assert_eq!(posted.len(), 2); + assert_eq!(posted[0].hours, "8.5"); + assert_eq!(posted[0].workdate, "20260901"); + assert_eq!(posted[0].employeenumber, "00000042"); + assert_eq!(posted[0].wbs_element, "WBS-1"); + assert_eq!(posted[1].wbs_element, "WBS-2"); + assert_eq!(posted[0].shorttext.len(), 50); + assert_eq!( + csharp_fields(&batch, 0).unwrap()[5].as_deref(), + Some("00000042") + ); + assert!(bapi_sink(&batch, &[u64::MAX]).is_err()); +} + +#[test] +fn current_hash_contracts_are_explicitly_incompatible() { + let batch = bind(&fixture(1)); + let abap = ordered_hash_projection( + &batch, + 0, + HashProfile::SimafPortAbap { + decimal_separator: '.', + }, + ) + .unwrap(); + let smb = ordered_hash_projection(&batch, 0, HashProfile::SmbMiddleware).unwrap(); + assert!(abap.starts_with("EntryID=entry-1|SourceSystem=SAP|EntryType=BillableHours|")); + assert!(smb.starts_with("ENTRY-1|SAP|BILLABLEHOURS|100|")); + assert!(abap.contains("|HoursLogged=8.50|")); + assert!(smb.contains("|8.50|DEV|")); + // Executed against the original pinned TimeTrackingHasher.cs under .NET 8; + // independently checked with Python hmac/SHA512 by verify_oracles.py. + assert_eq!(hash_projection(&smb, b"fixture-key"), + "261a980287713a250f5f2dcd6f7c304f255b1f5673b68604b660bb95f17c3d6570fd4f1e4072357b2e7c714a593478eea6dcc1adad5bc873ffd956923f9162a3"); + assert_ne!( + hash_projection(&abap, b"fixture-key"), + hash_projection(&smb, b"fixture-key") + ); + let comma = ordered_hash_projection( + &batch, + 0, + HashProfile::SimafPortAbap { + decimal_separator: ',', + }, + ) + .unwrap(); + assert!(comma.contains("HoursLogged=8,50")); +} + +#[test] +fn unsupported_oracle_cases_fail_instead_of_claiming_equivalence() { + for (field, value) in [(10, "0.125"), (3, "tenant-A"), (3, "0100"), (20, "ä")] { + let mut input = fixture(1); + input[field][0] = Some(value); + let batch = bind(&input); + assert!(ordered_hash_projection(&batch, 0, HashProfile::SmbMiddleware).is_err()); + } + let batch = bind(&fixture(1)); + assert!(bapi_sink(&batch, batch.alpha()).is_err()); +} diff --git a/crates/lance-graph-sap/tests/fold.rs b/crates/lance-graph-sap/tests/fold.rs index e5d9becff..4f796cc63 100644 --- a/crates/lance-graph-sap/tests/fold.rs +++ b/crates/lance-graph-sap/tests/fold.rs @@ -4,6 +4,7 @@ use lance_graph_mask_risc::{reference_execute, Planes, Value}; use lance_graph_sap::query::CatsQuery; #[test] +#[allow(clippy::needless_range_loop)] // Independent test-only row oracle across separate input columns. fn employee_date_activity_sum_matches_independent_oracle_across_word_tails() { for n in [0, 1, 63, 64, 65, 131, 4097] { let mut input = fixture(n); diff --git a/crates/lance-graph-sap/tools/csharp/Oracle.csproj b/crates/lance-graph-sap/tools/csharp/Oracle.csproj new file mode 100644 index 000000000..fdb3c24bd --- /dev/null +++ b/crates/lance-graph-sap/tools/csharp/Oracle.csproj @@ -0,0 +1,12 @@ + + + Exe + net8.0 + disable + + + + + + + diff --git a/crates/lance-graph-sap/tools/csharp/Program.cs b/crates/lance-graph-sap/tools/csharp/Program.cs new file mode 100644 index 000000000..33b24f1ab --- /dev/null +++ b/crates/lance-graph-sap/tools/csharp/Program.cs @@ -0,0 +1,32 @@ +// Test-only boundary harness. Compiles the ORIGINAL pinned C# implementations. +using System; +using System.IO; +using System.Linq; +using System.Globalization; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using System.Text; +using SmbOffice.Core.Interfaces.TimeTracking; +using SmbOffice.Core.Infrastructure.TimeTracking; +using UniversalDtoPoc.TimeTracking; + +var values = File.ReadAllLines(args[0]).Select(v => v == "\\N" ? null : v).ToArray(); +var fields = File.ReadAllLines(args[1]).Skip(1).Select(l => l.Split('\t')[4]).ToArray(); +var mirror = new TimeTrackingEntryDto { AdditionalMetadata = new AdditionalMetadataDto() }; +var children = mirror.GetType().GetProperties().Select(p => p.GetValue(mirror)).ToArray(); +var smb = new TimeTrackingEntry(); +for (int i = 0; i < fields.Length; ++i) { + var child = children.Single(c => c.GetType().GetProperty(fields[i]) != null); + var property = child.GetType().GetProperty(fields[i]); + object value = values[i]; + if (property.PropertyType == typeof(decimal)) value = decimal.Parse(values[i], CultureInfo.InvariantCulture); + if (property.PropertyType == typeof(bool)) value = bool.Parse(values[i]); + property.SetValue(child, value); + var flat = typeof(TimeTrackingEntry).GetProperty(fields[i], BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (flat != null) flat.SetValue(smb, flat.PropertyType == typeof(int) ? int.Parse(values[i], CultureInfo.InvariantCulture) : value); + Console.WriteLine(value is decimal d ? d.ToString("G29", CultureInfo.InvariantCulture) : value is bool b ? b.ToString().ToLowerInvariant() : value ?? "\\N"); +} +foreach (var child in children) Validator.ValidateObject(child, new ValidationContext(child), true); +var hasher = new TimeTrackingHasher(Encoding.UTF8.GetBytes("fixture-key")); +Console.WriteLine(hasher.PrepareHashData(smb)); +Console.WriteLine(hasher.CalculateHash(smb)); diff --git a/crates/lance-graph-sap/tools/verify_oracles.py b/crates/lance-graph-sap/tools/verify_oracles.py new file mode 100644 index 000000000..6162a6c73 --- /dev/null +++ b/crates/lance-graph-sap/tools/verify_oracles.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Execute pinned C# implementations and compare all fields/hash with Rust. + +python3 tools/verify_oracles.py EVIDENCE_ROOT [dotnet] [cargo] +An ABAP runtime is NOT emulated or claimed by this harness. +""" +from pathlib import Path +import subprocess +import sys +import hashlib +import hmac + +here = Path(__file__).resolve().parents[1] +root = Path(sys.argv[1]).resolve() +dotnet = sys.argv[2] if len(sys.argv) > 2 else 'dotnet' +cargo = sys.argv[3] if len(sys.argv) > 3 else 'cargo' +subprocess.run([sys.executable, str(here/'tools/verify_sources.py'), str(root)], check=True) +subprocess.run([dotnet, 'build', str(here/'tools/csharp/Oracle.csproj'), '-o', str(here/'target/csharp-oracle'), '-p:EvidenceRoot='+str(root), '--nologo'], check=True) +cs = subprocess.check_output([dotnet, str(here/'target/csharp-oracle/Oracle.dll'), str(here/'fixtures/cats.txt'), str(here/'schema.tsv')], text=True) +rs = subprocess.check_output([cargo, '+stable', 'run', '--quiet', '--manifest-path', str(here/'Cargo.toml'), '--example', 'oracle'], text=True) +assert cs.splitlines() == rs.splitlines(), (cs, rs) +lines = rs.splitlines() +assert len(lines) == 25 +assert hmac.new(b'fixture-key', lines[-2].encode(), hashlib.sha512).hexdigest() == lines[-1] +print('PASS: 23 fields + ordered projection + HMAC: original SIMAFPort/SMB C# == Rust; Python HMAC agrees.') From 7b7ed999e4de56f0a21c6aec832d92f8afc16a89 Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Tue, 22 Sep 2026 06:03:45 +0200 Subject: [PATCH 06/14] test(sap): pin only the harvested reusable adapter vocabulary W5: the same NUMC adapter serves employee and approver, UTC adapter serves four fields, and width checks plus lossless dictionaries serve the evidenced CHAR domains. Keep these local; no new generic SAP type system, fabricated scale or ALPHA exit is justified. --- crates/lance-graph-sap/tests/vocabulary.rs | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 crates/lance-graph-sap/tests/vocabulary.rs diff --git a/crates/lance-graph-sap/tests/vocabulary.rs b/crates/lance-graph-sap/tests/vocabulary.rs new file mode 100644 index 000000000..0e1a6666b --- /dev/null +++ b/crates/lance-graph-sap/tests/vocabulary.rs @@ -0,0 +1,28 @@ +mod common; +use common::*; +use lance_graph_sap::schema::FIELDS; + +#[test] +fn repeated_domains_share_one_adapter_and_char_values_remain_lossless() { + // W5: only demonstrated reuse is generalized: the two PERNR fields, + // four UTC fields, and width-constrained dictionary fields. No DDIC engine. + assert_eq!(FIELDS[5].native_type, FIELDS[21].native_type); + assert_eq!(FIELDS[5].carrier, FIELDS[21].carrier); + assert_eq!(FIELDS[5].width, FIELDS[21].width); + let mut input = fixture(3); + input[5] = vec![Some("00000000"), Some("00000042"), Some("99999999")]; + input[21] = input[5].clone(); + input[6] = vec![Some("0000000123"), Some("123"), Some("123 ")]; + input[7][0] = Some("123456789012345678901234"); + input[20][0] = Some("naïve — Grüße"); // lossless text storage, independent of hash support + let batch = bind(&input); + for row in 0..3 { + assert_eq!(batch.edge_value(5, row), batch.edge_value(21, row)); + assert_eq!(batch.edge_value(6, row).unwrap().as_deref(), input[6][row]); + } + assert_eq!(batch.edge_value(7, 0).unwrap().as_deref(), input[7][0]); + assert_eq!(batch.edge_value(20, 0).unwrap().as_deref(), input[20][0]); + // No invented ALPHA conversion: distinct source values stay distinct. + assert_ne!(batch.edge_value(6, 0), batch.edge_value(6, 1)); + assert_ne!(batch.edge_value(6, 1), batch.edge_value(6, 2)); +} From 55ed450876a30eb8bcc64ac0527f8677888911c5 Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Tue, 22 Sep 2026 06:03:46 +0200 Subject: [PATCH 07/14] feat(sap): converge CATS view masks with the Odoo field ABI W6: after CATS fold and boundary differential pass, inspect odoo-rs ef03731c00f5e2796df3185bd69526153af7e253. Its mint_wide_mask delegates to the same contract constructor. Pin real account.move corpus/view inputs (216 fields, 57 selected); exercise high ordinal bits. No Odoo source changes or shared ERP metamodel. --- crates/lance-graph-sap/fixtures/odoo-view.tsv | 217 ++++++++++++++++++ crates/lance-graph-sap/src/schema.rs | 16 +- .../lance-graph-sap/tests/view_convergence.rs | 42 ++++ .../tools/harvest_odoo_view.py | 43 ++++ 4 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 crates/lance-graph-sap/fixtures/odoo-view.tsv create mode 100644 crates/lance-graph-sap/tests/view_convergence.rs create mode 100644 crates/lance-graph-sap/tools/harvest_odoo_view.py diff --git a/crates/lance-graph-sap/fixtures/odoo-view.tsv b/crates/lance-graph-sap/fixtures/odoo-view.tsv new file mode 100644 index 000000000..8d265c2fb --- /dev/null +++ b/crates/lance-graph-sap/fixtures/odoo-view.tsv @@ -0,0 +1,217 @@ +field present +abnormal_amount_warning 0 +abnormal_date_warning 0 +adjusting_entries_count 1 +adjusting_entry_origin_label 1 +adjusting_entry_origin_moves_count 1 +alerts 1 +always_tax_exigible 0 +amount_paid 0 +amount_residual 1 +amount_residual_signed 0 +amount_tax 0 +amount_tax_signed 0 +amount_total 0 +amount_total_in_currency_signed 0 +amount_total_signed 0 +amount_total_words 0 +amount_untaxed 0 +amount_untaxed_in_currency_signed 0 +amount_untaxed_signed 0 +authorized_transaction_ids 0 +auto_post_until 1 +bank_partner_id 1 +checked 1 +commercial_partner_id 1 +company_id 1 +currency_id 1 +date 1 +debit_note_count 0 +delivery_date 1 +direction_sign 0 +display_inactive_currency_warning 1 +display_link_qr_code 0 +display_name 0 +display_qr_code 1 +display_send_button 0 +duplicated_ref_ids 1 +edi_blocking_level 0 +edi_error_count 0 +edi_error_message 0 +edi_show_abandon_cancel_button 0 +edi_show_cancel_button 0 +edi_show_force_cancel_button 0 +edi_state 0 +edi_web_services_to_process 0 +expected_currency_rate 1 +fiscal_position_id 1 +has_reconciled_entries 1 +hide_post_button 1 +highest_name 1 +highlight_send_button 0 +incoterm_location 1 +invoice_currency_rate 1 +invoice_date 1 +invoice_date_due 1 +invoice_filter_type_domain 1 +invoice_has_outstanding 1 +invoice_incoterm_id 1 +invoice_incoterm_placeholder 1 +invoice_line_ids 1 +invoice_partner_display_name 0 +invoice_payment_term_id 1 +invoice_payments_widget 1 +invoice_user_id 1 +invoice_vendor_bill_id 1 +is_being_sent 1 +is_draft_duplicated_ref_ids 0 +is_exact_move_duplicate 0 +is_landed_costs_line 0 +is_storno 0 +journal_id 1 +l10n_ar_afip_concept 0 +l10n_ar_withholding_ids 0 +l10n_bg_document_number 0 +l10n_bg_document_type 0 +l10n_eg_long_id 0 +l10n_eg_qr_code 0 +l10n_eg_submission_number 0 +l10n_eg_uuid 0 +l10n_es_edi_facturae_reason_code 0 +l10n_es_edi_is_required 0 +l10n_es_edi_verifactu_available_clave_regimens 0 +l10n_es_edi_verifactu_clave_regimen 0 +l10n_es_edi_verifactu_qr_code 0 +l10n_es_edi_verifactu_show_cancel_button 0 +l10n_es_edi_verifactu_state 0 +l10n_es_edi_verifactu_warning 0 +l10n_es_edi_verifactu_warning_level 0 +l10n_es_is_simplified 0 +l10n_es_payment_means 0 +l10n_es_tbai_is_required 0 +l10n_es_tbai_state 0 +l10n_fr_is_company_french 0 +l10n_gcc_invoice_tax_amount 0 +l10n_gcc_line_name 0 +l10n_gr_edi_alerts 0 +l10n_gr_edi_attachment_id 0 +l10n_gr_edi_available_inv_type 0 +l10n_gr_edi_cls_mark 0 +l10n_gr_edi_enable_send_expense_classification 0 +l10n_gr_edi_enable_send_invoices 0 +l10n_gr_edi_enable_view_mydata 0 +l10n_gr_edi_inv_type 0 +l10n_gr_edi_mark 0 +l10n_gr_edi_need_correlated 0 +l10n_gr_edi_need_payment_method 0 +l10n_gr_edi_payment_method 0 +l10n_gr_edi_state 0 +l10n_hr_payment_unreported 0 +l10n_hr_process_type 0 +l10n_hu_edi_attachment_filename 0 +l10n_hu_edi_message_html 0 +l10n_id_coretax_add_info_07 0 +l10n_id_coretax_add_info_08 0 +l10n_id_coretax_efaktur_available 0 +l10n_id_coretax_facility_info_07 0 +l10n_id_coretax_facility_info_08 0 +l10n_id_kode_transaksi 0 +l10n_in_ewaybill_expiry_date 0 +l10n_in_ewaybill_name 0 +l10n_in_state_id 0 +l10n_it_document_type 0 +l10n_it_edi_button_label 0 +l10n_it_edi_doi_amount 0 +l10n_it_edi_doi_date 0 +l10n_it_edi_doi_id 0 +l10n_it_edi_doi_use 0 +l10n_it_edi_doi_warning 0 +l10n_it_edi_is_self_invoice 0 +l10n_it_partner_is_public_administration 0 +l10n_it_partner_pa 0 +l10n_it_payment_method 0 +l10n_jo_edi_computed_xml 0 +l10n_jo_edi_invoice_type 0 +l10n_jo_edi_is_needed 0 +l10n_jo_edi_uuid 0 +l10n_ke_cu_show_send_button 0 +l10n_latam_available_document_type_ids 0 +l10n_latam_document_number 0 +l10n_latam_document_type_id 0 +l10n_latam_manual_document_number 0 +l10n_latam_use_documents 0 +l10n_my_edi_display_tax_exemption_reason 0 +l10n_my_edi_state 0 +l10n_my_invoice_need_edi 0 +l10n_ro_edi_state 0 +l10n_rs_edi_is_eligible 0 +l10n_rs_edi_uuid 0 +l10n_rs_tax_date_obligations_code 0 +l10n_sa_qr_code_str 0 +l10n_tr_exemption_code_domain_list 0 +l10n_tr_exemption_code_id 0 +l10n_tr_gib_invoice_type 0 +l10n_tw_edi_carrier_number 0 +l10n_tw_edi_carrier_number_2 0 +l10n_tw_edi_carrier_type 0 +l10n_tw_edi_invoice_type 0 +l10n_tw_edi_is_b2b 0 +l10n_tw_edi_is_print 0 +l10n_tw_edi_is_zero_tax_rate 0 +l10n_tw_edi_love_code 0 +l10n_vn_edi_invoice_state 0 +l10n_vn_edi_invoice_symbol 0 +landed_costs_visible 0 +move_sent_values 0 +name 1 +name_placeholder 0 +narration 1 +need_cancel_request 1 +needed_terms 0 +needed_terms_dirty 0 +nemhandel_can_send_response 0 +nemhandel_move_state 0 +next_payment_date 0 +no_followup 0 +partner_bank_id 1 +partner_credit_warning 0 +partner_id 1 +partner_shipping_id 1 +payment_count 1 +payment_reference 1 +payment_state 1 +payment_term_details 0 +peppol_can_send_response 0 +peppol_is_sent 0 +peppol_move_state 0 +pos_order_count 0 +preferred_payment_method_line_id 1 +quick_edit_mode 1 +quick_encoding_vals 1 +reconciled_payment_ids 0 +sale_order_count 0 +sale_warning_text 0 +secured 1 +show_delivery_date 1 +show_discount_details 0 +show_journal 0 +show_name_warning 1 +show_payment_term_details 0 +show_reset_to_draft_button 1 +show_taxable_supply_date 0 +show_update_fpos 1 +status_in_payment 0 +suitable_journal_ids 1 +tax_country_code 0 +tax_lock_date_message 0 +tax_totals 1 +taxable_supply_date 1 +taxable_supply_date_placeholder 1 +taxes_legal_notes 0 +timesheet_count 0 +timesheet_total_duration 0 +transaction_count 0 +type_name 0 +ubl_cii_xml_filename 0 +website_id 0 +wip_production_count 0 diff --git a/crates/lance-graph-sap/src/schema.rs b/crates/lance-graph-sap/src/schema.rs index fa86959b3..1a16a1fe1 100644 --- a/crates/lance-graph-sap/src/schema.rs +++ b/crates/lance-graph-sap/src/schema.rs @@ -1,7 +1,7 @@ //! The harvested 23-field CATS schema, ordered by the ABAP leaf declarations. //! Ordinals are append-only. DDIC scale, ALPHA exits and localized labels are //! absent: none is defined by the pinned source. Widths come from the C# mirror. -use lance_graph_contract::class_view::{ClassId, ClassView}; +use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; use lance_graph_mask_risc::LaneKind; use lance_graph_quack::Col; @@ -261,6 +261,20 @@ pub struct CatsSchema { fields: Vec, } impl CatsSchema { + /// Explicit cold/UI projection boundary. The shared constructor is the + /// same one used by od_ontology::view_mask::mint_wide_mask. Unknown names + /// are refused before that constructor (which otherwise ignores them). + pub fn realize_projection(&self, names: &[&str]) -> Option { + let universe: Vec<_> = FIELDS.iter().map(|f| f.technical_name).collect(); + let present: Option> = names + .iter() + .map(|name| { + self.resolve(name) + .map(|col| FIELDS[usize::from(col.0)].technical_name) + }) + .collect(); + WideFieldMask::from_universe_present(&universe, &present?).ok() + } pub fn new(class: ClassId, category: u8) -> Self { Self { class, diff --git a/crates/lance-graph-sap/tests/view_convergence.rs b/crates/lance-graph-sap/tests/view_convergence.rs new file mode 100644 index 000000000..ec5545c11 --- /dev/null +++ b/crates/lance-graph-sap/tests/view_convergence.rs @@ -0,0 +1,42 @@ +use lance_graph_contract::class_view::{ClassView, WideFieldMask}; +use lance_graph_sap::schema::CatsSchema; + +#[test] +fn sap_and_real_odoo_view_inputs_use_the_same_field_abi() { + let schema = CatsSchema::new(42, 0); + let sap = schema + .realize_projection(&["EmployeeNumber", "ActivityType", "HoursLogged"]) + .unwrap(); + assert_eq!(sap, WideFieldMask::from_positions(&[5, 10, 11])); + assert_eq!( + sap, + schema + .realize_projection(&["employee_number", "activity_type", "hours_logged"]) + .unwrap() + ); + assert!(schema.realize_projection(&["not_a_field"]).is_none()); + assert_eq!(schema.fields(42)[5].label, "employee_number"); + + // Odoo ef03731c, source blobs verified by tools/harvest_odoo_view.py. + // This executes the shared contract on harvested inputs; it is not a claim + // to have compiled the entire Odoo/OGAR dependency graph. + let fields: Vec<_> = include_str!("../fixtures/odoo-view.tsv") + .lines() + .skip(1) + .map(|l| l.split_once('\t').unwrap()) + .collect(); + let universe: Vec<_> = fields.iter().map(|(name, _)| *name).collect(); + let present: Vec<_> = fields + .iter() + .filter(|(_, p)| *p == "1") + .map(|(name, _)| *name) + .collect(); + let odoo = WideFieldMask::from_universe_present(&universe, &present).unwrap(); + assert_eq!(universe.len(), 216); + assert_eq!(odoo.count(), 57); + for (i, (_, present)) in fields.iter().enumerate() { + assert_eq!(odoo.has(i as u8), *present == "1"); + } + assert!((64..216).any(|i| odoo.has(i))); + assert_eq!(odoo.intersect(&WideFieldMask::full_for(216)), odoo); +} diff --git a/crates/lance-graph-sap/tools/harvest_odoo_view.py b/crates/lance-graph-sap/tools/harvest_odoo_view.py new file mode 100644 index 000000000..b82f34a02 --- /dev/null +++ b/crates/lance-graph-sap/tools/harvest_odoo_view.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""W6: harvest pinned real Odoo inputs for the shared projection test. + +Does not compile Odoo or merge its source metamodel with SAP's. +Usage: python3 tools/harvest_odoo_view.py /path/to/odoo-rs +""" +import hashlib +import json +from pathlib import Path +import sys +import xml.etree.ElementTree as ET + +root = Path(sys.argv[1]) +pins = { + 'crates/od-ontology/src/view_mask.rs': '723e50cf4ed929853369267e0e1d9061f6a07cd2', + 'data/account_move_form_view.xml': '32dcce31d739d85b34887c73f9aa993ccef35bfa', + 'data/slice_2.spo.ndjson': 'a854b501aa4a595385c7e60d4a22e6058ce3d315', +} +for path, sha in pins.items(): + data = (root/path).read_bytes() + assert hashlib.sha1(b'blob '+str(len(data)).encode()+b'\0'+data).hexdigest() == sha +source = (root/'crates/od-ontology/src/view_mask.rs').read_text() +assert 'lance_graph_contract::class_view::WideFieldMask::from_universe_present(' in source +universe = set() +for line in (root/'data/slice_2.spo.ndjson').read_text().splitlines(): + t = json.loads(line) + if t['p'] == 'rdf:type' and t['o'] == 'ogit:Property' and t['s'].startswith('odoo:account_move.'): + name = t['s'][len('odoo:account_move.'):] + if '.' not in name: universe.add(name) +record = ET.fromstring((root/'data/account_move_form_view.xml').read_text()) +arch = record.find("field[@name='arch']") +present = set() +def visit(node): + for child in node: + if child.tag == 'field': + present.add(child.attrib['name']) + # Descendants belong to a comodel, not account.move. + else: visit(child) +visit(arch) +assert len(universe) == 216 and len(universe & present) == 57 +out = Path(__file__).resolve().parents[1]/'fixtures/odoo-view.tsv' +out.write_text('field\tpresent\n'+''.join(f'{name}\t{int(name in present)}\n' for name in sorted(universe))) +print('PASS: pinned real account.move universe=216, view intersection=57; common WideFieldMask owner confirmed.') From 0213afe2a74e1b1c4f7971382a1c4a824819bb0f Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Tue, 22 Sep 2026 06:03:48 +0200 Subject: [PATCH 08/14] ci(sap): run the CATS capstone and expose a runnable example --- .github/workflows/sap-cats-abi.yml | 37 ++++++++++++++++++++++ crates/lance-graph-sap/examples/cats.rs | 36 +++++++++++++++++++++ crates/lance-graph-sap/tests/vocabulary.rs | 4 +-- 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/sap-cats-abi.yml create mode 100644 crates/lance-graph-sap/examples/cats.rs diff --git a/.github/workflows/sap-cats-abi.yml b/.github/workflows/sap-cats-abi.yml new file mode 100644 index 000000000..d92c7acee --- /dev/null +++ b/.github/workflows/sap-cats-abi.yml @@ -0,0 +1,37 @@ +name: SAP CATS ABI +on: + pull_request: + paths: + - 'crates/lance-graph-sap/**' + - 'crates/lance-graph-quack/**' + - 'crates/lance-graph-mask-risc/**' + - 'crates/lance-graph-contract/**' + - '.github/workflows/sap-cats-abi.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + cats: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + path: lance-graph + - uses: actions/checkout@v4 + with: + repository: AdaWorldAPI/ndarray + ref: main + path: ndarray + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Record tested substrate + run: git -C ndarray rev-parse HEAD + - name: Format + run: cargo +stable fmt --manifest-path lance-graph/crates/lance-graph-sap/Cargo.toml -- --check + - name: Lint + run: cargo +stable clippy --manifest-path lance-graph/crates/lance-graph-sap/Cargo.toml --all-targets -- -D warnings + - name: ABI and boundary tests + run: cargo +stable test --manifest-path lance-graph/crates/lance-graph-sap/Cargo.toml + - name: Executable CATS slice + run: cargo +stable run --manifest-path lance-graph/crates/lance-graph-sap/Cargo.toml --example cats diff --git a/crates/lance-graph-sap/examples/cats.rs b/crates/lance-graph-sap/examples/cats.rs new file mode 100644 index 000000000..ac2bc55d2 --- /dev/null +++ b/crates/lance-graph-sap/examples/cats.rs @@ -0,0 +1,36 @@ +//! Run: cargo run --manifest-path crates/lance-graph-sap/Cargo.toml --example cats +use lance_graph_sap::{bind::CatsBatch, edge::*, query::CatsQuery, schema::CatsSchema}; +fn main() -> Result<(), Box> { + let fields: Vec<_> = include_str!("../fixtures/cats.txt") + .lines() + .map(|s| if s == "\\N" { None } else { Some(s) }) + .collect(); + let mut input: [Vec<_>; 23] = std::array::from_fn(|i| vec![fields[i]; 4]); + input[5][3] = Some("00000007"); + input[11][2] = Some("OPS"); + input[10][2] = Some("0.125"); + input[20] = vec![Some("123456789012345678901234567890123456789012345678901234567890"); 4]; + let batch = CatsBatch::bind( + CatsSchema::new(42, 0), + std::array::from_fn(|i| input[i].as_slice()), + )?; + let mut query = CatsQuery::prepare(&batch, "00000042", "2026-09-01", "2026-09-30")?; + let mut sums = vec![0; query.groups()]; + let kept = query + .execute_into(&mut sums) + .map_err(|e| format!("execution: {e:?}"))?; + println!("Exact scale: {}", batch.scale()); + for total in activity_totals(&batch, &sums)? { + println!("{} = {} hours", total.activity_type, total.hours); + } + let records = bapi_sink(&batch, kept)?; + println!( + "{}: {} selected original assignments", + BAPI_FUNCTION, + records.len() + ); + for record in records { + println!("{record:?}"); + } + Ok(()) +} diff --git a/crates/lance-graph-sap/tests/vocabulary.rs b/crates/lance-graph-sap/tests/vocabulary.rs index 0e1a6666b..2a7b25505 100644 --- a/crates/lance-graph-sap/tests/vocabulary.rs +++ b/crates/lance-graph-sap/tests/vocabulary.rs @@ -16,9 +16,9 @@ fn repeated_domains_share_one_adapter_and_char_values_remain_lossless() { input[7][0] = Some("123456789012345678901234"); input[20][0] = Some("naïve — Grüße"); // lossless text storage, independent of hash support let batch = bind(&input); - for row in 0..3 { + for (row, customer) in input[6].iter().enumerate() { assert_eq!(batch.edge_value(5, row), batch.edge_value(21, row)); - assert_eq!(batch.edge_value(6, row).unwrap().as_deref(), input[6][row]); + assert_eq!(batch.edge_value(6, row).unwrap().as_deref(), *customer); } assert_eq!(batch.edge_value(7, 0).unwrap().as_deref(), input[7][0]); assert_eq!(batch.edge_value(20, 0).unwrap().as_deref(), input[20][0]); From c8bd16e441da3106af25e58f12ce76ddedad8a34 Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Tue, 22 Sep 2026 06:03:49 +0200 Subject: [PATCH 09/14] =?UTF-8?q?ci(sap):=20check=20out=20ndarray=20master?= =?UTF-8?q?=20=E2=80=94=20its=20default=20branch=20is=20master,=20not=20ma?= =?UTF-8?q?in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job died at fetch on every attempt (three retries, exit 1) before any Rust ran, so none of the crate's checks had executed in CI. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GXUahz73MZxtxWcfpHp9dG --- .github/workflows/sap-cats-abi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sap-cats-abi.yml b/.github/workflows/sap-cats-abi.yml index d92c7acee..07879f39e 100644 --- a/.github/workflows/sap-cats-abi.yml +++ b/.github/workflows/sap-cats-abi.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v4 with: repository: AdaWorldAPI/ndarray - ref: main + ref: master path: ndarray - uses: dtolnay/rust-toolchain@stable with: From 29463fb60258c389e5383bb0e20400d121f533da Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Tue, 22 Sep 2026 06:03:50 +0200 Subject: [PATCH 10/14] ci(sap): do not persist checkout credentials Both checkouts persisted GITHUB_TOKEN by default while the job runs PR-controlled Rust under cargo test / cargo run (CodeRabbit on #1257). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GXUahz73MZxtxWcfpHp9dG --- .github/workflows/sap-cats-abi.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/sap-cats-abi.yml b/.github/workflows/sap-cats-abi.yml index 07879f39e..47330ab13 100644 --- a/.github/workflows/sap-cats-abi.yml +++ b/.github/workflows/sap-cats-abi.yml @@ -17,11 +17,13 @@ jobs: - uses: actions/checkout@v4 with: path: lance-graph + persist-credentials: false - uses: actions/checkout@v4 with: repository: AdaWorldAPI/ndarray ref: master path: ndarray + persist-credentials: false - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy From 04a0219c06569ffbabe69aa49182f535ab2f3f6d Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Tue, 22 Sep 2026 06:03:51 +0200 Subject: [PATCH 11/14] sap: the BAPI assignment order is an ordinal map over canonical field identity `BAPI_ORDINALS` (with positionally aligned `BAPI_PARAMETERS`) names the BAPI parameter order as a permutation of a selection of `FIELDS`; `bapi_sink` reads through it, so the wire struct is filled by the map and is the boundary's shape, not a copied normal form. Test pins that the map is a permutation over the canonical basis, matches the pinned ABAP assignment, and that every posted value equals the canonical value at the mapped ordinal. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GXUahz73MZxtxWcfpHp9dG --- crates/lance-graph-sap/src/edge.rs | 37 +++++++++++--- crates/lance-graph-sap/tests/edges.rs | 70 ++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/crates/lance-graph-sap/src/edge.rs b/crates/lance-graph-sap/src/edge.rs index 4fe4de523..c3f781772 100644 --- a/crates/lance-graph-sap/src/edge.rs +++ b/crates/lance-graph-sap/src/edge.rs @@ -9,6 +9,26 @@ use sha2::Sha512; /// Pinned ABAP prepare_hash_data order, also used (without names) by SMB. pub const HASH_ORDINALS: [usize; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 20, 21, 22]; +/// The BAPI assignment order as a coordinate map over the canonical field +/// identity: position `k` of the BAPI parameter list reads canonical +/// ordinal `BAPI_ORDINALS[k]`. Same fields, same cardinality, different +/// coordinates — a permutation of a selection of [`FIELDS`], not a second +/// field vocabulary. [`bapi_sink`] reads through this map; the wire struct +/// it fills is the boundary's shape, never a copied normal form. +pub const BAPI_ORDINALS: [usize; 8] = [5, 9, 10, 11, 7, 8, 6, 20]; + +/// The BAPI parameter names, positionally aligned with [`BAPI_ORDINALS`]. +pub const BAPI_PARAMETERS: [&str; 8] = [ + "EMPLOYEENUMBER", + "WORKDATE", + "HOURS", + "ACTIVITYTYPE", + "WBS_ELEMENT", + "ORDERID", + "CUST_SPEC_PR", + "SHORTTEXT", +]; + #[derive(Debug, Clone, Copy)] pub enum HashProfile { /// Named, case-preserving fields. Decimal formatting depends on the SAP @@ -157,7 +177,8 @@ pub fn bapi_sink(batch: &CatsBatch, kept: &[u64]) -> Result, for row in lance_graph_mask_risc::materialize_rows(kept, batch.len()) { let field = |i| -> Result { Ok(batch.edge_value(i, row)?.unwrap_or_default()) }; - let notes = field(20)?; + let [emp, date_o, hours, act, wbs, order, cust, notes_o] = BAPI_ORDINALS; + let notes = field(notes_o)?; // ABAP source uses notes(50), not a documented safe truncation helper. // Restrict the fixture to at least 50 ASCII characters; do not silently // repair or claim parity for short strings / UTF-16 substring behavior. @@ -166,15 +187,15 @@ pub fn bapi_sink(batch: &CatsBatch, kept: &[u64]) -> Result, "BAPI notes(50) requires a 50-byte ASCII oracle fixture".into(), )); } - let date = field(9)?; + let date = field(date_o)?; result.push(BapiCatsInsert { - employeenumber: field(5)?, + employeenumber: field(emp)?, workdate: date[..10].replace('-', ""), - hours: field(10)?, - activitytype: field(11)?, - wbs_element: field(7)?, - orderid: field(8)?, - cust_spec_pr: field(6)?, + hours: field(hours)?, + activitytype: field(act)?, + wbs_element: field(wbs)?, + orderid: field(order)?, + cust_spec_pr: field(cust)?, shorttext: notes[..50].into(), }); } diff --git a/crates/lance-graph-sap/tests/edges.rs b/crates/lance-graph-sap/tests/edges.rs index 797058b47..f7266fddb 100644 --- a/crates/lance-graph-sap/tests/edges.rs +++ b/crates/lance-graph-sap/tests/edges.rs @@ -1,6 +1,6 @@ mod common; use common::*; -use lance_graph_sap::{edge::*, query::CatsQuery}; +use lance_graph_sap::{edge::*, query::CatsQuery, schema::FIELDS}; #[test] fn same_carrier_emits_totals_and_only_selected_original_bapi_assignments() { @@ -81,3 +81,71 @@ fn unsupported_oracle_cases_fail_instead_of_claiming_equivalence() { let batch = bind(&fixture(1)); assert!(bapi_sink(&batch, batch.alpha()).is_err()); } + +/// The BAPI order is a coordinate map over the canonical field identity — +/// a permutation of a selection, never a second vocabulary. Same data, +/// different coordinates. +#[test] +fn bapi_order_is_a_permutation_over_canonical_field_identity() { + // Class 1: distinct ordinals, all inside the canonical basis. + let mut seen = std::collections::BTreeSet::new(); + for &o in &BAPI_ORDINALS { + assert!(o < FIELDS.len()); + assert!(seen.insert(o), "ordinal {o} named twice"); + } + // Position k of the BAPI list is canonical field BAPI_ORDINALS[k] — + // the pinned ABAP assignment, read back through the map. + let names: Vec<&str> = BAPI_ORDINALS + .iter() + .map(|&o| FIELDS[o].technical_name) + .collect(); + assert_eq!( + names, + [ + "employee_number", + "work_date_utc", + "hours_logged", + "activity_type", + "project_code", + "task_code", + "customer_number", + "notes", + ] + ); + // It is genuinely a different coordinate system, not the canonical one. + assert!(BAPI_ORDINALS.windows(2).any(|w| w[1] < w[0])); + assert_eq!(BAPI_PARAMETERS.len(), BAPI_ORDINALS.len()); + + // The wire struct is filled THROUGH the map: every posted value equals + // the canonical value at the mapped ordinal for its row. + let mut input = fixture(3); + input[20] = vec![Some("123456789012345678901234567890123456789012345678901234567890"); 3]; + input[7][2] = Some("WBS-2"); + let batch = bind(&input); + let posted = bapi_sink(&batch, batch.alpha()).unwrap(); + assert_eq!(posted.len(), 3); + for (row, p) in posted.iter().enumerate() { + let at = |o: usize| batch.edge_value(o, row).unwrap().unwrap_or_default(); + let by_map = [ + p.employeenumber.clone(), + at(BAPI_ORDINALS[1])[..10].replace('-', ""), + p.hours.clone(), + p.activitytype.clone(), + p.wbs_element.clone(), + p.orderid.clone(), + p.cust_spec_pr.clone(), + p.shorttext.clone(), + ]; + let want = [ + at(BAPI_ORDINALS[0]), + p.workdate.clone(), + at(BAPI_ORDINALS[2]), + at(BAPI_ORDINALS[3]), + at(BAPI_ORDINALS[4]), + at(BAPI_ORDINALS[5]), + at(BAPI_ORDINALS[6]), + at(BAPI_ORDINALS[7])[..50].to_string(), + ]; + assert_eq!(by_map, want, "row {row}"); + } +} From 609740d84adf91ced5cf04d05a56b16fcc78682f Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Tue, 22 Sep 2026 06:04:39 +0200 Subject: [PATCH 12/14] fix(sap): fold on ndarray 318 before boundary mask realization --- .claude/board/AGENT_LOG.md | 19 ++++ .claude/board/LATEST_STATE.md | 19 ++++ ...d-does-not-retain-a-population-bitmap-1.md | 21 ++++ .claude/board/entries/README.md | 3 +- .github/workflows/sap-cats-abi.yml | 2 +- crates/lance-graph-sap/examples/cats.rs | 8 +- crates/lance-graph-sap/src/bind.rs | 23 ++--- crates/lance-graph-sap/src/edge.rs | 6 +- crates/lance-graph-sap/src/query.rs | 98 +++++++++++-------- crates/lance-graph-sap/tests/binding.rs | 1 - crates/lance-graph-sap/tests/edges.rs | 10 +- crates/lance-graph-sap/tests/fold.rs | 38 +++++-- crates/lance-graph-sap/tests/no_alloc.rs | 2 +- 13 files changed, 178 insertions(+), 72 deletions(-) create mode 100644 .claude/board/entries/2026-09-22-e-cats-fold-does-not-retain-a-population-bitmap-1.md diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md index d5e84806e..8777d9931 100644 --- a/.claude/board/AGENT_LOG.md +++ b/.claude/board/AGENT_LOG.md @@ -1,3 +1,22 @@ +## 2026-09-22 — SAP/CATS fold-first dependency stack (PR #1257, unmerged) + +- Substrate: ndarray #318 `d0376505`; lance-graph #1256 `4d27032e`. + #1246 and #1247 are already ancestors. SAP CI pins the ndarray commit. +- `CatsQuery` lowers one `Agg::GroupSumI32` through Quack. Bounded tile + scratch remains; no population bitmap is retained by binding or aggregation. + `select_into` requests a bitmap only for the terminal BAPI assignment sink. +- Removed the unused all-ones alpha allocation. Positive hours are checked + at binding so every subset/group fits I64 despite the kernel's wrapping sum. +- A rotation changes coordinate-to-ordinal references; it does not transpose + carrier storage. The existing BAPI ordinal permutation is an edge read map. +- SAP tests pass across 0/1/63/64/65/131/4097 inputs, against independent + arithmetic and the substrate reference interpreter; repeat resets, bounded + scratch, zero hot allocations and original-assignment boundary selection. + Strict SAP clippy passes. No nanosecond performance claim is made. +- Inventory: isolated `lance-graph-sap` consumer contains source descriptors, + `CatsSchema: ClassView`, column binding to existing `LaneRef`, cold codebooks, + Quack orchestration and explicit C#/hash/BAPI sinks. No SAP execution engine. + ## 2026-09-14 (5) — the ceiling guard CodeRabbit found on the fold commit, and a falsifier that tracked its own constant **The finding, against `061d12b` (post-fold, not the reviewed `687042f`).** diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 95454e79b..e9f5fb8fe 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,22 @@ +## 2026-09-22 — SAP/CATS fold-first dependency stack (PR #1257, unmerged) + +- Substrate: ndarray #318 `d0376505`; lance-graph #1256 `4d27032e`. + #1246 and #1247 are already ancestors. SAP CI pins the ndarray commit. +- `CatsQuery` lowers one `Agg::GroupSumI32` through Quack. Bounded tile + scratch remains; no population bitmap is retained by binding or aggregation. + `select_into` requests a bitmap only for the terminal BAPI assignment sink. +- Removed the unused all-ones alpha allocation. Positive hours are checked + at binding so every subset/group fits I64 despite the kernel's wrapping sum. +- A rotation changes coordinate-to-ordinal references; it does not transpose + carrier storage. The existing BAPI ordinal permutation is an edge read map. +- SAP tests pass across 0/1/63/64/65/131/4097 inputs, against independent + arithmetic and the substrate reference interpreter; repeat resets, bounded + scratch, zero hot allocations and original-assignment boundary selection. + Strict SAP clippy passes. No nanosecond performance claim is made. +- Inventory: isolated `lance-graph-sap` consumer contains source descriptors, + `CatsSchema: ClassView`, column binding to existing `LaneRef`, cold codebooks, + Quack orchestration and explicit C#/hash/BAPI sinks. No SAP execution engine. + ## 2026-09-19 (3) — PR #1252 merged (`8545a555`); `main` is `8545a555` - **Docs only, no contract inventory delta.** Ruling now on `main`: *we think diff --git a/.claude/board/entries/2026-09-22-e-cats-fold-does-not-retain-a-population-bitmap-1.md b/.claude/board/entries/2026-09-22-e-cats-fold-does-not-retain-a-population-bitmap-1.md new file mode 100644 index 000000000..d96b58355 --- /dev/null +++ b/.claude/board/entries/2026-09-22-e-cats-fold-does-not-retain-a-population-bitmap-1.md @@ -0,0 +1,21 @@ +## 2026-09-22 — E-CATS-FOLD-DOES-NOT-RETAIN-A-POPULATION-BITMAP-1 — CATS aggregate lowers to one tiled grouped terminal; bitmap realization is a requested boundary sink + +## 2026-09-22 — SAP/CATS fold-first dependency stack (PR #1257, unmerged) + +- Substrate: ndarray #318 `d0376505`; lance-graph #1256 `4d27032e`. + #1246 and #1247 are already ancestors. SAP CI pins the ndarray commit. +- `CatsQuery` lowers one `Agg::GroupSumI32` through Quack. Bounded tile + scratch remains; no population bitmap is retained by binding or aggregation. + `select_into` requests a bitmap only for the terminal BAPI assignment sink. +- Removed the unused all-ones alpha allocation. Positive hours are checked + at binding so every subset/group fits I64 despite the kernel's wrapping sum. +- A rotation changes coordinate-to-ordinal references; it does not transpose + carrier storage. The existing BAPI ordinal permutation is an edge read map. +- SAP tests pass across 0/1/63/64/65/131/4097 inputs, against independent + arithmetic and the substrate reference interpreter; repeat resets, bounded + scratch, zero hot allocations and original-assignment boundary selection. + Strict SAP clippy passes. No nanosecond performance claim is made. +- Inventory: isolated `lance-graph-sap` consumer contains source descriptors, + `CatsSchema: ClassView`, column binding to existing `LaneRef`, cold codebooks, + Quack orchestration and explicit C#/hash/BAPI sinks. No SAP execution engine. + diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index cf992ec5c..be1b26ab5 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,10 +25,11 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -144 entries, 2026-08-06 .. 2026-08-31. +145 entries, 2026-08-06 .. 2026-09-22. | date | entry id | finding | file | |---|---|---|---| +| 2026-09-22 | `E-CATS-FOLD-DOES-NOT-RETAIN-A-POPULATION-BITMAP-1` | CATS aggregate lowers to one tiled grouped terminal; bitmap realization is a requested boundary sink | [2026-09-22-e-cats-fold-does-not-retain-a-population-bitmap-1.md](2026-09-22-e-cats-fold-does-not-retain-a-population-bitmap-1.md) | | 2026-08-31 | `E-Q8-THE-SIX-DOES-NO-WORK-A-DEGREE-ABLATION-COLLAPSES-THE-HEX-OVERLAYS-ENTIRE-ADVANTAGE-1` | B passes every pre-registered gate and the pass is unattributable: at degree 1 it scores identically with 5.5× less memory | [2026-08-31-e-q8-the-six-does-no-work-a-degree-ablation-collapses-the-hex-overlays-entire-advantage-1.md](2026-08-31-e-q8-the-six-does-no-work-a-degree-ablation-collapses-the-hex-overlays-entire-advantage-1.md) | | 2026-08-27 | `E-THE-FUSED-PAYLOAD-IS-INERT-AT-EVERY-EXECUTION-GATE-THAT-CONSUMES-IT-1` | | [2026-08-27-e-the-fused-payload-is-inert-at-every-execution-gate-that-consumes-it-1.md](2026-08-27-e-the-fused-payload-is-inert-at-every-execution-gate-that-consumes-it-1.md) | | 2026-08-26 | `E-THE-PERTURBATION-FIELD-NEVER-REACHED-THE-MASK-ALU-1` | the three DTOs are an adapter seam, not an ALU chain: PerturbationDto.energy is dropped, top_k collapses to a min/max window, and the p64 mask ALU is DTO-blind; 4096==4096 is not an address identity (S/4×O/4 vs codebook) — probe gate filed | [2026-08-26-e-the-perturbation-field-never-reached-the-mask-alu-1.md](2026-08-26-e-the-perturbation-field-never-reached-the-mask-alu-1.md) | diff --git a/.github/workflows/sap-cats-abi.yml b/.github/workflows/sap-cats-abi.yml index 47330ab13..accb7e5e9 100644 --- a/.github/workflows/sap-cats-abi.yml +++ b/.github/workflows/sap-cats-abi.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v4 with: repository: AdaWorldAPI/ndarray - ref: master + ref: d0376505a47a817af72f5482e107359d946f956b # ndarray PR #318 path: ndarray persist-credentials: false - uses: dtolnay/rust-toolchain@stable diff --git a/crates/lance-graph-sap/examples/cats.rs b/crates/lance-graph-sap/examples/cats.rs index ac2bc55d2..be697f7e2 100644 --- a/crates/lance-graph-sap/examples/cats.rs +++ b/crates/lance-graph-sap/examples/cats.rs @@ -16,14 +16,18 @@ fn main() -> Result<(), Box> { )?; let mut query = CatsQuery::prepare(&batch, "00000042", "2026-09-01", "2026-09-30")?; let mut sums = vec![0; query.groups()]; - let kept = query + query .execute_into(&mut sums) .map_err(|e| format!("execution: {e:?}"))?; + let mut kept = vec![0; batch.len().div_ceil(64)]; + query + .select_into(&mut kept) + .map_err(|e| format!("selection: {e:?}"))?; println!("Exact scale: {}", batch.scale()); for total in activity_totals(&batch, &sums)? { println!("{} = {} hours", total.activity_type, total.hours); } - let records = bapi_sink(&batch, kept)?; + let records = bapi_sink(&batch, &kept)?; println!( "{}: {} selected original assignments", BAPI_FUNCTION, diff --git a/crates/lance-graph-sap/src/bind.rs b/crates/lance-graph-sap/src/bind.rs index de20004c8..8ddedc1f6 100644 --- a/crates/lance-graph-sap/src/bind.rs +++ b/crates/lance-graph-sap/src/bind.rs @@ -1,7 +1,7 @@ //! Cold, fallible ingestion. The output owns numeric columns; execution borrows //! them as the existing LaneRef. Dictionaries are lossless edge metadata. use crate::schema::{CatsSchema, FIELDS, FIELD_COUNT}; -use lance_graph_mask_risc::{words_for, LaneRef}; +use lance_graph_mask_risc::LaneRef; pub const EMPLOYEE: usize = 5; pub const WORK_DATE: usize = 9; @@ -44,7 +44,6 @@ pub struct CatsBatch { pub schema: CatsSchema, columns: Vec, dictionaries: [Vec; FIELD_COUNT], - alpha: Vec, len: usize, scale: u32, } @@ -90,14 +89,18 @@ impl CatsBatch { .collect::>()?; scale = decimals.iter().map(|(_, s)| *s).max().unwrap_or(0); let mut lane = Vec::with_capacity(len); + let mut total = 0i64; for (coefficient, s) in decimals { let scaled = coefficient .checked_mul(10i128.pow(scale - s)) .ok_or_else(|| error("decimal overflow"))?; - lane.push( - i32::try_from(scaled) - .map_err(|_| error("exact batch decimal scale exceeds I32 carrier"))?, - ); + let value = i32::try_from(scaled) + .map_err(|_| error("exact batch decimal scale exceeds I32 carrier"))?; + // Hours are positive: bounding their total bounds every subset/group. + total = total + .checked_add(i64::from(value)) + .ok_or_else(|| error("batch hours exceed exact I64 sum"))?; + lane.push(value); } Column::I32(lane) } else if f.native_type == "pernr_d" { @@ -152,15 +155,10 @@ impl CatsBatch { columns.push(Column::I32( dates.iter().map(|v| (v / 1_000_000) as i32).collect(), )); - let mut alpha = vec![u64::MAX; words_for(len)]; - if !len.is_multiple_of(64) { - *alpha.last_mut().unwrap() = (1u64 << (len % 64)) - 1; - } Ok(Self { schema, columns, dictionaries, - alpha, len, scale, }) @@ -174,9 +172,6 @@ impl CatsBatch { pub fn scale(&self) -> u32 { self.scale } - pub fn alpha(&self) -> &[u64] { - &self.alpha - } pub fn lanes(&self) -> [LaneRef<'_>; FIELD_COUNT + 1] { std::array::from_fn(|i| self.columns[i].borrow()) } diff --git a/crates/lance-graph-sap/src/edge.rs b/crates/lance-graph-sap/src/edge.rs index c3f781772..f81017630 100644 --- a/crates/lance-graph-sap/src/edge.rs +++ b/crates/lance-graph-sap/src/edge.rs @@ -169,7 +169,11 @@ pub const BAPI_FUNCTION: &str = "BAPI_CATIMESHEETMGR_INSERT"; /// Explicit row sink. The substrate's sole materializer is used only here. /// Aggregated hours MUST NOT be posted under an invented project/date. pub fn bapi_sink(batch: &CatsBatch, kept: &[u64]) -> Result, BindError> { - if kept.len() != batch.alpha().len() || kept.iter().zip(batch.alpha()).any(|(a, b)| a & !b != 0) + if kept.len() != batch.len().div_ceil(64) + || (!batch.len().is_multiple_of(64) + && kept + .last() + .is_some_and(|word| word >> (batch.len() % 64) != 0)) { return Err(BindError("invalid terminal selection mask".into())); } diff --git a/crates/lance-graph-sap/src/query.rs b/crates/lance-graph-sap/src/query.rs index 711fbebef..8d2dd7018 100644 --- a/crates/lance-graph-sap/src/query.rs +++ b/crates/lance-graph-sap/src/query.rs @@ -1,13 +1,16 @@ //! Consumer orchestration only. Quack lowers; mask-RISC executes. This module //! never evaluates a predicate, visits source rows, or looks up a label. use crate::bind::{numc, utc, BindError, CatsBatch, ACTIVITY, EMPLOYEE, HOURS, WORK_DAY}; -use lance_graph_mask_risc::{execute, ExecError, Operand, Planes, Scratch, Value}; -use lance_graph_quack::{lower_group_by, Agg, Cmp, Col, Filter, GroupBy, GroupPlan}; +use lance_graph_mask_risc::{ + execute_into, ExecError, Foreign, Out, Planes, Program, Scratch, Value, +}; +use lance_graph_quack::{lower, Agg, Cmp, Col, Filter, Query}; /// Bound to one immutable batch, so dictionary IDs cannot cross populations. pub struct CatsQuery<'a> { batch: &'a CatsBatch, - plan: GroupPlan, + plan: Program, + selection: Program, filter_scratch: Scratch<'static>, group_scratch: Scratch<'static>, } @@ -27,43 +30,50 @@ impl<'a> CatsQuery<'a> { if from > to { return Err(BindError("reversed date range".into())); } - let plan = lower_group_by( - &GroupBy { - filter: Filter::and([ - Filter::cmp(Col(EMPLOYEE as u16), Cmp::EqU32(employee)), - Filter::cmp(Col(WORK_DAY as u16), Cmp::GeI32(from as i32)), - Filter::cmp(Col(WORK_DAY as u16), Cmp::LeI32(to as i32)), - ]), + let filter = Filter::and([ + Filter::cmp(Col(EMPLOYEE as u16), Cmp::EqU32(employee)), + Filter::cmp(Col(WORK_DAY as u16), Cmp::GeI32(from as i32)), + Filter::cmp(Col(WORK_DAY as u16), Cmp::LeI32(to as i32)), + ]); + let plan = lower(&Query { + filter: filter.clone(), + agg: Agg::GroupSumI32 { key: Col(ACTIVITY as u16), - groups: batch.activity_groups(), - agg: Agg::SumI32(Col(HOURS as u16)), + val: Col(HOURS as u16), }, - 0, - ) + }) + .map_err(|e| BindError(format!("Quack lowering: {e:?}")))?; + let selection = lower(&Query { + filter, + agg: Agg::Rows, + }) .map_err(|e| BindError(format!("Quack lowering: {e:?}")))?; - let filter_scratch = Scratch::for_program(&plan.filter, batch.len()) + let filter_scratch = Scratch::for_program(&selection, batch.len()) .map_err(|e| BindError(format!("scratch: {e:?}")))?; - let group_scratch = Scratch::for_program(&plan.groups[0], batch.len()) + let group_scratch = Scratch::for_program(&plan, batch.len()) .map_err(|e| BindError(format!("scratch: {e:?}")))?; Ok(Self { batch, plan, + selection, filter_scratch, group_scratch, }) } pub fn groups(&self) -> usize { - self.plan.groups.len() + self.batch.activity_groups() as usize } - pub fn plan(&self) -> &GroupPlan { + pub fn plan(&self) -> &Program { &self.plan } - /// Hot phase: no allocation, text, reflection, DTOs or intermediate rows. - /// Sums are in the batch's explicit decimal scale. Returns the BORROWED - /// survivor mask for an optional terminal BAPI sink; no mask copy. - /// Quack currently runs one kept filter plus one fold per dictionary key, - /// not a claimed single-pass grouped aggregate. - pub fn execute_into(&mut self, sums: &mut [i64]) -> Result<&[u64], ExecError> { + /// Scratch is bounded by the substrate tile size, independent of population. + pub fn scratch_words(&self) -> usize { + self.group_scratch.words() * self.group_scratch.slots() + + self.filter_scratch.words() * self.filter_scratch.slots() + } + /// One Quack grouped fold, with no retained population mask or rows. + /// Overwrites sums on every call, including an empty selection. + pub fn execute_into(&mut self, sums: &mut [i64]) -> Result<(), ExecError> { if sums.len() != self.groups() { return Err(ExecError::LenMismatch { what: "group sums", @@ -77,24 +87,32 @@ impl<'a> CatsQuery<'a> { masks: &[], lanes: &lanes, }; - let kept = execute(&self.plan.filter, &input, &mut self.filter_scratch, None)?; - let Value::Mask(Operand::Scratch(slot)) = kept else { - unreachable!("lowered comparison filter") - }; - let mask = self.filter_scratch.slot(slot).expect("validated scratch"); - let masks = [mask]; - let grouped = Planes { + let value = execute_into( + &self.plan, + &input, + &Foreign::NONE, + &mut self.group_scratch, + Out::I64(sums), + )?; + debug_assert_eq!(value, Value::GroupSummed); + Ok(()) + } + /// Optional boundary projection. Only a sink demanding original assignments + /// supplies a population-sized mask; the aggregate does not retain one. + pub fn select_into(&mut self, mask: &mut [u64]) -> Result<(), ExecError> { + let lanes = self.batch.lanes(); + let input = Planes { n_rows: self.batch.len(), - masks: &masks, + masks: &[], lanes: &lanes, }; - for (program, sum) in self.plan.groups.iter().zip(sums) { - let Value::SumI64(value) = execute(program, &grouped, &mut self.group_scratch, None)? - else { - unreachable!("SumI32 terminal") - }; - *sum = value; - } - Ok(mask) + execute_into( + &self.selection, + &input, + &Foreign::NONE, + &mut self.filter_scratch, + Out::Mask(mask), + )?; + Ok(()) } } diff --git a/crates/lance-graph-sap/tests/binding.rs b/crates/lance-graph-sap/tests/binding.rs index 18b336b06..14293636f 100644 --- a/crates/lance-graph-sap/tests/binding.rs +++ b/crates/lance-graph-sap/tests/binding.rs @@ -21,7 +21,6 @@ fn columns_borrow_without_copy_and_preserve_null_zero_empty_and_time() { assert_eq!(a.as_ptr(), b.as_ptr()); assert_eq!(a[0], 8500); assert_eq!(a[1], 125); - assert_eq!(batch.alpha(), &[u64::MAX, 1]); assert_eq!(batch.edge_value(6, 0).unwrap(), None); assert_eq!(batch.edge_value(6, 1).unwrap().as_deref(), Some("")); assert_eq!( diff --git a/crates/lance-graph-sap/tests/edges.rs b/crates/lance-graph-sap/tests/edges.rs index f7266fddb..598fa27d7 100644 --- a/crates/lance-graph-sap/tests/edges.rs +++ b/crates/lance-graph-sap/tests/edges.rs @@ -11,7 +11,9 @@ fn same_carrier_emits_totals_and_only_selected_original_bapi_assignments() { let batch = bind(&input); let mut query = CatsQuery::prepare(&batch, "00000042", "2026-09-01", "2026-09-30").unwrap(); let mut sums = vec![0; query.groups()]; - let kept = query.execute_into(&mut sums).unwrap(); + query.execute_into(&mut sums).unwrap(); + let mut kept = vec![0; batch.len().div_ceil(64)]; + query.select_into(&mut kept).unwrap(); let totals = activity_totals(&batch, &sums).unwrap(); assert_eq!( totals, @@ -20,7 +22,7 @@ fn same_carrier_emits_totals_and_only_selected_original_bapi_assignments() { hours: "17.0".into() }] ); - let posted = bapi_sink(&batch, kept).unwrap(); + let posted = bapi_sink(&batch, &kept).unwrap(); assert_eq!(posted.len(), 2); assert_eq!(posted[0].hours, "8.5"); assert_eq!(posted[0].workdate, "20260901"); @@ -79,7 +81,7 @@ fn unsupported_oracle_cases_fail_instead_of_claiming_equivalence() { assert!(ordered_hash_projection(&batch, 0, HashProfile::SmbMiddleware).is_err()); } let batch = bind(&fixture(1)); - assert!(bapi_sink(&batch, batch.alpha()).is_err()); + assert!(bapi_sink(&batch, &[1]).is_err()); } /// The BAPI order is a coordinate map over the canonical field identity — @@ -122,7 +124,7 @@ fn bapi_order_is_a_permutation_over_canonical_field_identity() { input[20] = vec![Some("123456789012345678901234567890123456789012345678901234567890"); 3]; input[7][2] = Some("WBS-2"); let batch = bind(&input); - let posted = bapi_sink(&batch, batch.alpha()).unwrap(); + let posted = bapi_sink(&batch, &[7]).unwrap(); assert_eq!(posted.len(), 3); for (row, p) in posted.iter().enumerate() { let at = |o: usize| batch.edge_value(o, row).unwrap().unwrap_or_default(); diff --git a/crates/lance-graph-sap/tests/fold.rs b/crates/lance-graph-sap/tests/fold.rs index 4f796cc63..7b23db575 100644 --- a/crates/lance-graph-sap/tests/fold.rs +++ b/crates/lance-graph-sap/tests/fold.rs @@ -1,6 +1,6 @@ mod common; use common::*; -use lance_graph_mask_risc::{reference_execute, Planes, Value}; +use lance_graph_mask_risc::{reference_execute_into, Foreign, Out, Planes, Terminal, Value}; use lance_graph_sap::query::CatsQuery; #[test] @@ -30,7 +30,15 @@ fn employee_date_activity_sum_matches_independent_oracle_across_word_tails() { let batch = bind(&input); let mut query = CatsQuery::prepare(&batch, "00000042", "2026-09-01", "2026-09-30").unwrap(); let mut sums = vec![0; query.groups()]; - let mask = query.execute_into(&mut sums).unwrap().to_vec(); // TEST-only copy for oracle + query.execute_into(&mut sums).unwrap(); + assert!(matches!( + query.plan().terminal, + Terminal::GroupSumI32 { .. } + )); + assert!( + query.scratch_words() <= 64, + "scratch must stay tile-bounded" + ); for (i, &sum) in sums.iter().enumerate() { let expected = match batch.activity_label(i as u32) { Some("DEV") => expected_dev, @@ -41,16 +49,32 @@ fn employee_date_activity_sum_matches_independent_oracle_across_word_tails() { assert_eq!(sum, expected, "n={n}, group={i}"); } let lanes = batch.lanes(); - let masks = [mask.as_slice()]; let planes = Planes { n_rows: n, - masks: &masks, + masks: &[], lanes: &lanes, }; - for (program, sum) in query.plan().groups.iter().zip(sums) { + let mut reference = vec![0; query.groups()]; + assert_eq!( + reference_execute_into( + query.plan(), + &planes, + &Foreign::NONE, + Out::I64(&mut reference) + ) + .unwrap(), + Value::GroupSummed + ); + assert_eq!(sums, reference); + let first = sums.clone(); + query.execute_into(&mut sums).unwrap(); + assert_eq!(sums, first, "execution must reset the accumulated sink"); + let mut mask = vec![0; n.div_ceil(64)]; + query.select_into(&mut mask).unwrap(); + for i in 0..n { assert_eq!( - reference_execute(program, &planes, None).unwrap(), - Value::SumI64(sum) + (mask[i / 64] >> (i % 64)) & 1 != 0, + i % 3 != 0 && i % 5 != 0 ); } let mut absent = diff --git a/crates/lance-graph-sap/tests/no_alloc.rs b/crates/lance-graph-sap/tests/no_alloc.rs index 39d0ab0f0..178b4e88f 100644 --- a/crates/lance-graph-sap/tests/no_alloc.rs +++ b/crates/lance-graph-sap/tests/no_alloc.rs @@ -36,7 +36,7 @@ fn the_entire_bound_query_allocates_zero_bytes() { let mut query = CatsQuery::prepare(&batch, "00000042", "2026-09-01", "2026-09-30").unwrap(); let mut sums = vec![0; query.groups()]; ACTIVE.with(|a| a.set(true)); - let result = query.execute_into(&mut sums).map(|mask| mask.len()); + let result = query.execute_into(&mut sums); ACTIVE.with(|a| a.set(false)); assert!(result.is_ok()); assert_eq!(COUNT.with(Cell::get), 0); From cfa123106503e8b508d0ca926a2c818d352ffdb3 Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Tue, 22 Sep 2026 06:04:41 +0200 Subject: [PATCH 13/14] fix(sap): bind dictionary codes with a cold lookup index --- crates/lance-graph-sap/src/bind.rs | 15 ++++++++++----- crates/lance-graph-sap/tests/binding.rs | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/lance-graph-sap/src/bind.rs b/crates/lance-graph-sap/src/bind.rs index 8ddedc1f6..999e72313 100644 --- a/crates/lance-graph-sap/src/bind.rs +++ b/crates/lance-graph-sap/src/bind.rs @@ -131,16 +131,21 @@ impl CatsBatch { ) } else { let dict = &mut dictionaries[index]; + // Cold borrowed lookup only; stable codes follow first occurrence. + // Discard it after binding; reverse labels remain edge metadata. + let mut lookup = std::collections::HashMap::<&str, u32>::new(); let mut codes = Vec::with_capacity(len); for value in values { codes.push(if let Some(value) = value { - let position = if let Some(i) = dict.iter().position(|s| s == value) { - i + if let Some(&code) = lookup.get(value) { + code } else { + let code = u32::try_from(dict.len() + 1) + .map_err(|_| error("dictionary too large"))?; dict.push((*value).into()); - dict.len() - 1 - }; - u32::try_from(position + 1).map_err(|_| error("dictionary too large"))? + lookup.insert(value, code); + code + } } else { 0 }); diff --git a/crates/lance-graph-sap/tests/binding.rs b/crates/lance-graph-sap/tests/binding.rs index 14293636f..91cca5926 100644 --- a/crates/lance-graph-sap/tests/binding.rs +++ b/crates/lance-graph-sap/tests/binding.rs @@ -78,3 +78,23 @@ fn invalid_inputs_do_not_enter_the_abi() { .is_err()); assert!(bind(&fixture(0)).is_empty()); } + +#[test] +fn unique_text_codes_preserve_first_occurrence_and_edge_values() { + let labels: Vec<_> = (0..4097).map(|i| format!("note-{i}")).collect(); + let mut input = common::fixture(labels.len() + 2); + input[20] = labels.iter().map(|s| Some(s.as_str())).collect(); + input[20].extend([Some(labels[0].as_str()), None]); + let batch = common::bind(&input); + for (i, label) in labels.iter().enumerate() { + assert_eq!( + batch.edge_value(20, i).unwrap().as_deref(), + Some(label.as_str()) + ); + } + assert_eq!( + batch.edge_value(20, labels.len()).unwrap().as_deref(), + Some("note-0") + ); + assert_eq!(batch.edge_value(20, labels.len() + 1).unwrap(), None); +} From a540c1aeb77f67d09a2b00ea958b42c9204555fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 04:15:59 +0000 Subject: [PATCH 14/14] =?UTF-8?q?ci(sap):=20follow=20ndarray=20master=20ag?= =?UTF-8?q?ain=20=E2=80=94=20#318=20merged=20(ecb2f2c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commit pin was a stopgap while the kernels lived only on the PR branch; an internal head pin is not allowed to outlive its reason. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GXUahz73MZxtxWcfpHp9dG --- .github/workflows/sap-cats-abi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sap-cats-abi.yml b/.github/workflows/sap-cats-abi.yml index accb7e5e9..47330ab13 100644 --- a/.github/workflows/sap-cats-abi.yml +++ b/.github/workflows/sap-cats-abi.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v4 with: repository: AdaWorldAPI/ndarray - ref: d0376505a47a817af72f5482e107359d946f956b # ndarray PR #318 + ref: master path: ndarray persist-credentials: false - uses: dtolnay/rust-toolchain@stable