From b98569ff26273b048f8c3bd4355d3f23d8d37b7c Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Thu, 10 Sep 2026 21:45:27 +0530 Subject: [PATCH 01/75] Bind source entities to masters deterministically in the shared crate Every failure across four document-import engagements was binding a document's entities to the target book's masters, never reading the document. Bridge was growing two answers to it: an MCP-private prefix matcher that named one near-miss candidate as `exact_live_spelling`, and a desktop screen that correctly ranks nothing but narrows nothing either. Add `bridge_tally_core::master_binding` as the single contract both surfaces consume, per ADR 0016. It matches an identifier embedded in a master name before the name itself, binds only where a rule is unique on both sides, and never resolves a near-miss: it reports candidates with the rule that surfaced each, and no score. An empty catalogue is a typed refusal rather than a report full of "missing". Deletes `master_match` and `master_key` from agent_import and moves unicode-normalization down a layer with them. The write gate is unchanged: build_import_xml and the approved-post recheck still admit byte-exact names only. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 252 +++++ .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 18 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 1 - src-tauri/crates/bridge-tally-core/Cargo.toml | 1 + src-tauri/crates/bridge-tally-core/src/lib.rs | 1 + .../bridge-tally-core/src/master_binding.rs | 905 ++++++++++++++++++ .../src/master_binding_tests.rs | 507 ++++++++++ src-tauri/src/agent_catalog.rs | 2 +- src-tauri/src/agent_import.rs | 149 ++- src-tauri/src/agent_import_post.rs | 2 +- src-tauri/src/agent_import_tests.rs | 113 ++- src-tauri/src/source_draft/catalog.rs | 151 +++ src/source-draft-types.ts | 16 + tools/Cargo.lock | 10 + 16 files changed, 2046 insertions(+), 86 deletions(-) create mode 100644 docs/adr/0016-master-binding-authority.md create mode 100644 src-tauri/crates/bridge-tally-core/src/master_binding.rs create mode 100644 src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md new file mode 100644 index 00000000..61692393 --- /dev/null +++ b/docs/adr/0016-master-binding-authority.md @@ -0,0 +1,252 @@ +# ADR 0016: Master binding is deterministic, identifier-first, and never auto-resolves + +## Status + +Accepted for the shared binding contract in `bridge-tally-core`, consumed by the +agent/MCP layer and by the desktop source-draft flow. Master creation, ledger or +stock-item write authority, voucher generation, posting, and any model-assisted +or scored matching remain rejected without separate evidence. + +## Context + +Four document-import engagements have run to completion — one failed, three +clean, 152 vouchers, zero rejections. **Not one failure was OCR, parsing, or +model quality. Every failure was binding the document's entities to the target +book's masters.** + +- One engagement rejected 61 vouchers: seven ledger masters were missing, four + of them near-misses of ledgers that already existed, and suspense was posted + to an account that did not exist. +- One bank engagement survived only because ambiguous truncated payees were + parked in suspense rather than guessed. +- One sales engagement met five missing stock items, three near-duplicate sales + ledgers differing by one character and word order, and a customer whose ledger + name differed from the source name entirely. **Fuzzy name matching offered + three candidates and all three were the wrong person.** Only a mobile number + the operator had embedded in the ledger name identified them, and it matched + exactly. + +Bridge has two surfaces that need this and is currently growing two answers: + +- `agent_import.rs::master_match` (MCP `validate_masters`) compares a + case-and-whitespace-folded key by equality or prefix, and — for a near-miss — + emits `exact_live_spelling: candidates.first()`. That field names one + candidate as *the* live spelling with no evidence that it is; it is a silent + auto-resolution of exactly the case that caused the 61-voucher failure. +- PR #276 gave the desktop the opposite and correct behaviour: an operator loads + the observed ledger list and explicitly assigns a target, with a fresh reread + proving the selection still exists. It deliberately ranks nothing — but it + also narrows nothing, so the operator faces the entire catalog per entry. + +Neither surface matches on an embedded identifier, which is the one rule that +would have decided the case that defeated fuzzy matching. + +## Decision + +Binding is a **pure, deterministic function in `bridge-tally-core`** over +(one company's observed masters of one class, the entities named by one source +document). It performs no I/O, holds no transport handle, calls no model, and +depends on nothing above `bridge-tally-primitives`. Both surfaces consume it; +neither reimplements it. + +### 1. Inputs are valid by construction + +`MasterCatalog::new(class, names)` and `SourceEntity::new(...)` parse at the +boundary and fail closed with a typed `MasterBindingError`. `bind()` then takes +already-valid inputs and returns a report with no `Result`, so no caller can +re-check or compensate differently (P3). + +The constructor refuses, rather than degrades, on: + +- **an empty catalog** — `CatalogEmpty`. Binding against a book that was never + read is the May failure exactly, and it is now a typed error rather than a + report full of "missing"; +- **a catalog carrying the same name twice** — `CatalogDuplicateName`. If a name + does not identify one master, nothing downstream is meaningful; +- **an identifier hint that yields no identifier** — `IdentifierHintUnusable`. A + hint that silently does nothing is a trap (P7); +- bounds violations on entry count, entity count, and name length. + +A softer collision — two masters differing only in case, whitespace runs, or +dash and quote style — does **not** fail the catalog. It is carried as a +per-entity ambiguity instead, so every other master still binds and the +colliding pair surfaces in the unbound list where an operator can see it. +Failing a whole read to report one collision would block all the work it was +performed for. + +`MasterClass` is `Ledger` or `StockItem`. Both classes failed in practice, the +rules are identical for both, and the class is carried only so a report cannot +be applied to the wrong catalog. + +### 2. The identifier is the key; the name is a hint + +Operators bury phone numbers, account numbers, and part codes inside master +names. Where such an identifier is present it is matched **before** any name +comparison, because a name comparison on the same pair is actively misleading. + +Two identifier shapes are extracted, deterministically, from both source names +and master names, and additionally accepted from the caller when the source +document carries an identifier outside the name (a statement's payment +reference, say): + +- **Numeric** — a maximal digit run, allowing internal hyphens and slashes, of + at least `MIN_NUMERIC_IDENTIFIER_DIGITS` (8) digits. Canonical form is the + digits alone, so a punctuated account number and a plain one agree. Internal + *spaces* are deliberately not allowed: fusing separated digit groups would + manufacture identifiers out of unrelated numbers, so a spaced value fails + closed to a near-miss instead. Eight digits is the threshold at which a year, + a rate, a house number and a masked last-four cannot qualify — a last-four + written as digits falls through to near-miss rather than binding two accounts + that share four digits. +- **Code** — a token holding at least one letter and at least + `MIN_CODE_IDENTIFIER_DIGITS` (2) digits, of at least + `MIN_CODE_IDENTIFIER_CHARS` (4) alphanumeric characters. Canonical form is + uppercase alphanumerics, so a punctuated part number and an unpunctuated one + agree. + +One narrow exclusion applies to the numeric shape: an eight-digit run that reads +as a calendar date in 1900–2199 is a date, not an identifier. Without it two +unrelated period-labelled masters fuse on their period. The exclusion can only +make a bind less likely, never more, which is the safe direction for a rule +whose failure mode is posting against the wrong party. + +An identifier binds only when it is **unique on both sides**: exactly one +master in the catalog carries it, and the entity's identifiers select exactly +one master overall. Any conflict is `Ambiguous`, never a bind. This keeps the +rule safe in the case that motivates it — a shared identifier is evidence of a +naming collision the operator must see, not licence to choose. + +Identifier-first has one more guard. Where a decisive identifier points at one +master while the entity's name is byte-equal to a *different* master, two strong +signals disagree, and the disagreement is reported +(`IdentifierNameConflict`) rather than silently settled in the identifier's +favour. + +### 3. Name matching binds only on an exact or normalized-exact unique hit + +`Exact` is byte equality with the observed master name. `Normalized` is equality +under a comparison key that applies NFC, folds Unicode dash and quote variants +to ASCII, lowercases, and collapses whitespace — and only when exactly one +master shares that key. Nothing else binds. There is no edit distance, no +phonetic key, no token stemming, and no similarity threshold anywhere in the +implementation. + +### 4. Near-misses produce candidates and never resolve + +Every non-binding entity carries its candidates, each labelled with the **rule +that produced it** — `SharedIdentifier`, `NormalizedEqual`, `SourcePrefix`, +`CatalogPrefix`, or `SharedToken`. Candidates are ordered by rule and then by +name; **no candidate is marked best, first-choice, or `exact_live_spelling`, +and no numeric score is emitted at all.** + +A score is rejected as a matter of contract, not of tuning. A score invites a +threshold, a threshold auto-resolves, and auto-resolution is what put money +against the wrong parties. The vocabulary is therefore a *basis* — a fact about +which rule fired — and never a confidence value (P6: the marker is recorded, and +it records what was observed). + +`SharedToken` suppresses tokens that occur in more than +`COMMON_TOKEN_PERCENT` (10%) of a catalog of at least `COMMON_TOKEN_MIN_CATALOG` +(20) entries, so a catalog-wide word cannot pull in every master. The +suppression is measured from the catalog rather than from a built-in word list, +which keeps it free of language and domain assumptions. + +Candidates are capped at `MAX_CANDIDATES_PER_ENTITY` (25) with the true +`candidate_count` and an explicit `candidates_truncated` flag retained, so a +truncated list is never mistaken for a short one. + +### 5. Status vocabulary + +Per entity, exactly one of: + +| status | meaning | +| --- | --- | +| `Bound { catalog_name, basis }` | one master, decided by `Identifier`, `ExactName`, or `NormalizedName` | +| `Ambiguous { candidates, .. }` | more than one master is defensible, including every identifier conflict | +| `Unmatched { candidates, .. }` | no rule produced a candidate | + +`Ambiguous` and `Unmatched` are the **unbound list, which is the product**. Each +unbound entry carries the source name as given, a stable `safe_reason_code`, its +extracted identifiers as `unresolved_identity`, and its candidates. "Here is +what I could not bind, and why" is the operator's actual work item; it is not an +error path and is not logged as a failure. + +### 6. A fallback binding is constructed, never inferred + +An ambiguous entity must remain postable. `FallbackBinding::assign` accepts an +**unbound** entry and a catalog-verified fallback master (a suspense ledger), +and retains the entity's `unresolved_identity` so a later reallocation journal +can find it without re-reading the source. It cannot be constructed from a bound +entity, so "silently rebound something that already matched" is not a +representable state (P2). Binding itself never emits a fallback. + +### 7. Totals prove the run + +`BindingReport::totals()` reports `requested`, `bound`, `ambiguous`, +`unmatched`, and `unbound`. `requested == bound + unbound` and +`unbound == ambiguous + unmatched` are invariants asserted by test, matching the +control-total discipline that proved every clean engagement. + +### 8. Identity stays where identity is already proven + +The report names masters by their **observed name only**. It holds no GUID and +grants no authority. A caller that intends to act on a binding re-reads the +catalog and revalidates the selection through the existing admission path — +`StandardLedgerCatalog::bind_selected` plus a fresh +`StandardLedgerCatalogBinding::matches` — exactly as PR #276 already requires. +This preserves that PR's rule that matching text alone is never a selected or +approved target, and keeps GUIDs out of a portable crate that has no company +scope to check them against. + +Consequently a binding is a **proposal**, never an approval. It selects no +voucher, creates no master, and dispatches nothing. + +## Consequences + +- `agent_import.rs::master_match` and its private `master_key` are deleted and + `validate_masters` is re-expressed over the crate. `match_state` gains + `normalized` and `identifier` alongside `exact`, `near_miss` and `missing`, + and `exact_live_spelling` now appears only on a bound row. A caller reading + that field on a near-miss was reading a guess. +- The old implementation classified *every* normalized-equal name as a + near-miss, so a request differing from the live ledger only in case, + whitespace, or dash style produced a candidate list instead of an answer. + Those now bind and report the live spelling. +- `build_import_xml` and the approved-post recheck still admit **`exact` only**. + The import file carries the name verbatim, so a normalized or identifier bind + informs the operator without widening what may be written. This PR does not + move the write gate. +- The MCP result reports an unbound entity's `unresolved_identity` wrapped in + the same party-name marker as every other name, so egress redaction treats it + identically. It adds no exposure: those identifiers are extracted from the + requested name the same result already echoes. +- The desktop catalog load returns an advisory binding per source entry, so the + operator sees the few relevant ledgers rather than all of them. It confers no + authority: assignment still runs the unchanged apply path, which rereads the + catalog and proves the selection is current. An unusable capture narrows + nothing rather than failing a read the operator just performed. +- Stock items are covered by contract before a stock-item catalog read exists. + When that read lands it supplies names to the same constructor; nothing in + this contract changes. Until then the shipped consumers pass `Ledger`, so the + stock-item half of the recorded failure is designed for but not yet reachable + from a screen. +- Binding is pure computation over already-observed data, so P1's live-evidence + requirement is satisfied upstream by the catalog read that produces its input. + Its own tests are fabricated from a placeholder alphabet: they establish the + behaviour of the rules, and are not, and may not be presented as, evidence + about any Tally instance. + +## Alternatives rejected + +- **Fuzzy or scored matching (edit distance, trigram, phonetic).** Directly + disproven: on the case that mattered its three best candidates were three + different wrong people, and a fourth-ranked exact identifier was present. +- **Auto-resolving a single candidate.** A single candidate is exactly the + four-near-miss situation that rejected 61 vouchers. Uniqueness of a *guess* is + not evidence. +- **Building this in the MCP and migrating later.** The two surfaces would + diverge before the migration; the divergence has already begun in + `master_match` and this ADR ends it rather than duplicating it. +- **Putting binding in `bridge-tally-protocol`.** Binding parses no wire format + and needs no XML. `bridge-tally-core` is the portable contract layer and holds + the analogous reconciliation logic (P8: dependencies point inward). diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 53d05efd..15c41e76 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "208002baf29bae14a5268ee8c5535f0c2bff6ccb37b749b7a3e51226393394ce", + "compatibility_surface_sha256": "0aee44725e419e3f677a5e19062fd716d4448c123878eb10d0c4c8b3e8a18b19", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index dfce7d92..5510d3be 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -123,15 +123,15 @@ }, { "path": "src-tauri/Cargo.lock", - "sha256": "929a359a24c809dff87405f8103fe648ae60dba0aaba3229911ec694683708d7" + "sha256": "1b6484e09fa0cc08355dfc0cccda5abbbb4651426081401ab63caccf372b5245" }, { "path": "src-tauri/Cargo.toml", - "sha256": "5cfe6c1fba7b20dd7d7a65ffc96039f41be9d7d55129abe071f470d0b10baf5a" + "sha256": "d4071736b6a6e5cc10f8252c36c0de2cfc48de6c01a03da8e0cb95dfeed240f2" }, { "path": "src-tauri/crates/bridge-tally-core/Cargo.toml", - "sha256": "67868c232e5fcb21b8e0098732b8cb92f87353f90460cf21090feeb262ca1c31" + "sha256": "512cfa03c1a126c36433b6de54a9fabdd822d8d1ac5db8f8d3e2ef5e7ec370e0" }, { "path": "src-tauri/crates/bridge-tally-core/src/bills_reconciliation.rs", @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", - "sha256": "fe515176a64d96322b49843b96facfbe51c71e320ffbe03c36a8cf1860fe8249" + "sha256": "58674602eb3131c101ace7638d43cf550d74b29eb3848b0c908687bb2c9fbf9c" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -327,7 +327,7 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "8f071dcccbcf498275c22e996cb5d45dbe3ae214aa694167b0aec9a2fe268100" + "sha256": "007c1c97eb65929734345fe72b8b52a9cee8ca6adcb375654080a71f6576ca3a" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -575,7 +575,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "e81b7123d2208290a4b7c9a669731ddaecace5ec012d42c2061afae52c3383f6" + "sha256": "f283ace5705e679b9a25ea605fd53fc7775878b93065b0c552f4c4c9234d312d" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -771,7 +771,7 @@ }, { "path": "src/source-draft-types.ts", - "sha256": "27d08a63153498943a0cb5506b15f6b0f9e5733e4ff0456376e212d9cf0534e8" + "sha256": "429d2b69a2948f8fdcb7da602c5b895d2dff42c3db7f48ba44fc9d10db701321" }, { "path": "src/source-draft.css", @@ -795,7 +795,7 @@ }, { "path": "tools/Cargo.lock", - "sha256": "b68a1a0d5c735459b7280657ced1b7d426039266e361ec4d75b17b933bd1e785" + "sha256": "62d922fb0c6b8b7fe1313bfb9058f1991760bfd04bfc5e28552dc4ec9ef2e11a" }, { "path": "tools/bridge-tally-compatibility/Cargo.toml", @@ -842,5 +842,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "208002baf29bae14a5268ee8c5535f0c2bff6ccb37b749b7a3e51226393394ce" + "manifest_sha256": "0aee44725e419e3f677a5e19062fd716d4448c123878eb10d0c4c8b3e8a18b19" } \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f4061eb4..a8cc6ac5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -441,7 +441,6 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", - "unicode-normalization", "uuid", "windows-sys 0.61.2", "x509-parser", @@ -460,6 +459,7 @@ dependencies = [ "sha2 0.11.0", "thiserror 2.0.20", "tokio", + "unicode-normalization", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e3493167..1a733526 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -89,7 +89,6 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1", features = ["v4", "serde"] } -unicode-normalization = "0.1" x509-parser = "0.18" zeroize = "1" pdf-writer = "0.15.0" diff --git a/src-tauri/crates/bridge-tally-core/Cargo.toml b/src-tauri/crates/bridge-tally-core/Cargo.toml index 09a5cbb7..d3873e4c 100644 --- a/src-tauri/crates/bridge-tally-core/Cargo.toml +++ b/src-tauri/crates/bridge-tally-core/Cargo.toml @@ -15,6 +15,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" thiserror = "2" +unicode-normalization = "0.1" [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/src-tauri/crates/bridge-tally-core/src/lib.rs b/src-tauri/crates/bridge-tally-core/src/lib.rs index f45624f9..9860d52d 100644 --- a/src-tauri/crates/bridge-tally-core/src/lib.rs +++ b/src-tauri/crates/bridge-tally-core/src/lib.rs @@ -8,6 +8,7 @@ pub use bridge_tally_primitives::{ }; pub mod bills_reconciliation; +pub mod master_binding; mod pack_models; pub mod reconciliation; pub mod report_tie_out; diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs new file mode 100644 index 00000000..88672867 --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -0,0 +1,905 @@ +//! Deterministic binding of source-document entity names to one company's +//! observed masters. +//! +//! See `docs/adr/0016-master-binding-authority.md`. Three rules carry the whole +//! contract: an embedded identifier is matched before any name, nothing binds +//! unless it is unique on both sides, and a near-miss is never resolved — it is +//! reported with its candidates so an operator decides. +//! +//! This module performs no I/O, holds no company identity, and calls no model. +//! A returned binding is a proposal: a caller that intends to act on one +//! re-reads the catalog and revalidates the selection through the admission +//! path that owns identity. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use unicode_normalization::UnicodeNormalization; + +/// Most masters one catalog may carry. +pub const MAX_CATALOG_ENTRIES: usize = 20_000; +/// Most entities one binding request may name. +pub const MAX_SOURCE_ENTITIES: usize = 5_000; +/// Longest accepted master or source name, in characters. This bounds +/// pathological input; it is not a claim about what Tally accepts, and a +/// caller with a stricter contract of its own enforces that at its own +/// boundary. +pub const MAX_NAME_CHARS: usize = 16_384; +/// Most candidates retained per unbound entity. +pub const MAX_CANDIDATES_PER_ENTITY: usize = 25; +/// Most identifiers extracted from one name. +pub const MAX_IDENTIFIERS_PER_NAME: usize = 8; +/// Digits a numeric run needs before it is treated as an identifier. Eight +/// excludes a year, a rate, a house number and a masked last-four; a mobile, +/// an account number and a customer code all clear it. +pub const MIN_NUMERIC_IDENTIFIER_DIGITS: usize = 8; +/// Alphanumeric characters a mixed letter-and-digit token needs before it is +/// treated as a code identifier. +pub const MIN_CODE_IDENTIFIER_CHARS: usize = 4; +/// Digits a code identifier needs alongside at least one letter. +pub const MIN_CODE_IDENTIFIER_DIGITS: usize = 2; +/// Shortest comparison key that may take part in a prefix near-miss. +pub const MIN_PREFIX_KEY_CHARS: usize = 3; +/// Shortest token that may take part in a shared-token near-miss. +pub const MIN_TOKEN_CHARS: usize = 3; +/// Share of the catalog above which a token stops discriminating. +pub const COMMON_TOKEN_PERCENT: usize = 10; +/// Catalog size below which no token is treated as common. +pub const COMMON_TOKEN_MIN_CATALOG: usize = 20; + +/// The master class a catalog and a report belong to. Both classes have failed +/// in practice and the rules are identical for both; the class is carried so a +/// report cannot be applied against the wrong catalog. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MasterClass { + Ledger, + StockItem, +} + +/// Binding refuses rather than degrades. Every variant is a fail-closed +/// boundary check on input that was never observed, never usable, or already +/// undecidable before any matching ran. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum MasterBindingError { + /// Binding against a book that was never read is the failure this whole + /// contract exists to prevent. It is an error, not an empty report. + #[error("master catalog was empty")] + CatalogEmpty, + /// Two masters carry byte-identical names, so a name cannot identify one. + #[error("master catalog carried a duplicate name")] + CatalogDuplicateName, + #[error("master catalog exceeded its bound")] + CatalogTooLarge, + #[error("source entity list exceeded its bound")] + TooManySourceEntities, + #[error("name was blank")] + NameBlank, + #[error("name exceeded its bound")] + NameTooLong, + #[error("name carried a control character")] + NameUnsafe, + /// A caller-supplied identifier hint that yields no identifier would fail + /// silently, so it fails loudly instead. + #[error("identifier hint carried no usable identifier")] + IdentifierHintUnusable, + #[error("fallback master was not a current catalog entry")] + FallbackNotInCatalog, +} + +impl MasterBindingError { + /// A stable code safe to surface to an operator or a tool result. + pub fn safe_reason_code(&self) -> &'static str { + match self { + Self::CatalogEmpty => "master_catalog_empty", + Self::CatalogDuplicateName => "master_catalog_duplicate_name", + Self::CatalogTooLarge => "master_catalog_too_large", + Self::TooManySourceEntities => "master_source_entities_too_many", + Self::NameBlank => "master_name_blank", + Self::NameTooLong => "master_name_too_long", + Self::NameUnsafe => "master_name_unsafe", + Self::IdentifierHintUnusable => "master_identifier_hint_unusable", + Self::FallbackNotInCatalog => "master_fallback_not_in_catalog", + } + } +} + +/// The shape an identifier was recognized by. Both canonicalize away the +/// punctuation an operator happened to type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IdentifierKind { + /// A digit run: a mobile, an account number, a numeric customer code. + Numeric, + /// A mixed letter-and-digit token: a part number, a registration code. + Code, +} + +/// One stable identifier embedded in a name, in canonical form. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +pub struct Identifier { + pub kind: IdentifierKind, + pub value: String, +} + +/// The rule that produced a candidate. There is deliberately no score: a score +/// invites a threshold, and a threshold auto-resolves the case this contract +/// exists to keep in front of a human. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CandidateRule { + /// Shares an embedded identifier, but the identifier was not decisive. + SharedIdentifier, + /// Equal under the comparison key, but the key was not unique. + NormalizedEqual, + /// The catalog name extends the source name — the source was truncated. + CatalogPrefix, + /// The source name extends the catalog name. + SourcePrefix, + /// Shares a token that discriminates within this catalog. + SharedToken, +} + +impl CandidateRule { + fn rank(self) -> u8 { + match self { + Self::SharedIdentifier => 0, + Self::NormalizedEqual => 1, + Self::CatalogPrefix => 2, + Self::SourcePrefix => 3, + Self::SharedToken => 4, + } + } +} + +/// A master an operator may choose, with the rule that surfaced it. No +/// candidate is marked best, and the order is rule-then-name, not similarity. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Candidate { + pub catalog_name: String, + pub rule: CandidateRule, +} + +/// Why an entity did not bind. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UnboundReason { + /// One embedded identifier is carried by more than one master. + IdentifierConflict, + /// An identifier and an exact name pointed at different masters. + IdentifierNameConflict, + /// More than one master shares the comparison key. + NameAmbiguous, + /// Candidates exist but none was decisive. This is the four-near-miss case + /// that rejected a batch once; a single candidate stays here too. + NearMiss, + /// No rule produced a candidate. The master is probably missing. + NoCandidate, +} + +impl UnboundReason { + /// A stable code safe to surface to an operator or a tool result. + pub fn safe_reason_code(self) -> &'static str { + match self { + Self::IdentifierConflict => "master_binding_identifier_conflict", + Self::IdentifierNameConflict => "master_binding_identifier_name_conflict", + Self::NameAmbiguous => "master_binding_name_ambiguous", + Self::NearMiss => "master_binding_near_miss", + Self::NoCandidate => "master_binding_no_candidate", + } + } +} + +/// The evidence that decided a bind. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BindingBasis { + /// An embedded identifier unique on both sides. Checked before any name. + Identifier, + /// Byte equality with the observed master name. + ExactName, + /// Equality under the comparison key, unique in the catalog. + NormalizedName, +} + +/// What could not be bound, and why. This is the operator's work item, not an +/// error path. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Unresolved { + pub reason: UnboundReason, + /// Identifiers extracted from the source name and from caller hints, + /// retained so a fallback posting can be reallocated later without + /// re-reading the source document. + pub unresolved_identity: Vec, + pub candidates: Vec, + /// Candidates found before truncation. + pub candidate_count: usize, + pub candidates_truncated: bool, +} + +/// Exactly one outcome per source entity. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum BindingStatus { + Bound { + catalog_name: String, + basis: BindingBasis, + }, + /// More than one master is defensible. + Ambiguous(Unresolved), + /// No master is defensible. + Unmatched(Unresolved), +} + +/// One source entity and its outcome. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct EntityBinding { + pub position: usize, + /// The name exactly as the source document gave it. + pub source_name: String, + #[serde(flatten)] + pub status: BindingStatus, +} + +impl EntityBinding { + pub fn bound_name(&self) -> Option<&str> { + match &self.status { + BindingStatus::Bound { catalog_name, .. } => Some(catalog_name.as_str()), + _ => None, + } + } + + pub fn unresolved(&self) -> Option<&Unresolved> { + match &self.status { + BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved) => { + Some(unresolved) + } + BindingStatus::Bound { .. } => None, + } + } +} + +/// Control totals for one run. `requested == bound + unbound` and +/// `unbound == ambiguous + unmatched` always hold. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub struct BindingTotals { + pub requested: usize, + pub bound: usize, + pub unbound: usize, + pub ambiguous: usize, + pub unmatched: usize, +} + +/// The result of one binding run. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct BindingReport { + class: MasterClass, + entities: Vec, +} + +impl BindingReport { + pub fn class(&self) -> MasterClass { + self.class + } + + pub fn entities(&self) -> &[EntityBinding] { + &self.entities + } + + /// Everything that bound. + pub fn bound(&self) -> impl Iterator { + self.entities + .iter() + .filter(|entity| matches!(entity.status, BindingStatus::Bound { .. })) + } + + /// Everything that did not — the product of this module. + pub fn unbound(&self) -> impl Iterator { + self.entities + .iter() + .filter(|entity| !matches!(entity.status, BindingStatus::Bound { .. })) + } + + pub fn totals(&self) -> BindingTotals { + let mut totals = BindingTotals { + requested: self.entities.len(), + bound: 0, + unbound: 0, + ambiguous: 0, + unmatched: 0, + }; + for entity in &self.entities { + match entity.status { + BindingStatus::Bound { .. } => totals.bound += 1, + BindingStatus::Ambiguous(_) => { + totals.unbound += 1; + totals.ambiguous += 1; + } + BindingStatus::Unmatched(_) => { + totals.unbound += 1; + totals.unmatched += 1; + } + } + } + totals + } +} + +/// An ambiguous entity parked against a fallback master, with its unresolved +/// identity retained for later reallocation. +/// +/// Constructed only from an entity that did not bind, so rebinding something +/// that already matched is not a representable state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FallbackBinding { + position: usize, + source_name: String, + fallback_name: String, + retained: Vec, + reason: UnboundReason, +} + +impl FallbackBinding { + /// Parks one unbound entity against a catalog-verified fallback master. + /// + /// Refuses a bound entity and refuses a fallback name that is not a current + /// catalog entry — a suspense ledger that does not exist is how one + /// engagement lost a batch. + pub fn assign( + entity: &EntityBinding, + catalog: &MasterCatalog, + fallback_name: &str, + ) -> Result { + let unresolved = entity + .unresolved() + .ok_or(MasterBindingError::FallbackNotInCatalog)?; + let fallback = catalog + .exact(fallback_name) + .ok_or(MasterBindingError::FallbackNotInCatalog)?; + Ok(Self { + position: entity.position, + source_name: entity.source_name.clone(), + fallback_name: fallback.to_string(), + retained: unresolved.unresolved_identity.clone(), + reason: unresolved.reason, + }) + } + + pub fn position(&self) -> usize { + self.position + } + + pub fn source_name(&self) -> &str { + &self.source_name + } + + pub fn fallback_name(&self) -> &str { + &self.fallback_name + } + + pub fn reason(&self) -> UnboundReason { + self.reason + } + + pub fn retained(&self) -> &[Identifier] { + &self.retained + } + + /// The identity to carry into a narration so the parked amount can be + /// reallocated without re-reading the source. Empty when the source name + /// carried no identifier at all — which is itself worth seeing. + pub fn retained_tag(&self) -> String { + self.retained + .iter() + .map(|identifier| { + let kind = match identifier.kind { + IdentifierKind::Numeric => "numeric", + IdentifierKind::Code => "code", + }; + format!("{kind}:{}", identifier.value) + }) + .collect::>() + .join(" ") + } +} + +/// One entity named by a source document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceEntity { + position: usize, + name: String, + key: String, + identifiers: Vec, +} + +impl SourceEntity { + /// Parses one source name at the boundary, extracting identifiers from it. + pub fn new(position: usize, name: &str) -> Result { + Self::with_identifier_hints(position, name, std::iter::empty::<&str>()) + } + + /// Parses one source name together with identifiers the document carries + /// outside the name — a statement's payment reference, a report's mobile + /// column. A hint that yields no identifier is refused rather than ignored. + pub fn with_identifier_hints<'a>( + position: usize, + name: &str, + hints: impl IntoIterator, + ) -> Result { + let name = validated_name(name)?; + let mut identifiers = extract_identifiers(&name); + for hint in hints { + let extracted = extract_identifiers(hint); + if extracted.is_empty() { + return Err(MasterBindingError::IdentifierHintUnusable); + } + identifiers.extend(extracted); + } + identifiers.sort(); + identifiers.dedup(); + identifiers.truncate(MAX_IDENTIFIERS_PER_NAME); + Ok(Self { + position, + key: comparison_key(&name), + name, + identifiers, + }) + } + + pub fn position(&self) -> usize { + self.position + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn identifiers(&self) -> &[Identifier] { + &self.identifiers + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CatalogEntry { + name: String, + key: String, + identifiers: Vec, + tokens: BTreeSet, +} + +/// One company's observed masters of one class, indexed for binding. +/// +/// Valid by construction: `bind` cannot fail because everything that could fail +/// was decided here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MasterCatalog { + class: MasterClass, + entries: Vec, + by_name: BTreeMap, + by_key: BTreeMap>, + by_identifier: BTreeMap>, + by_token: BTreeMap>, + common_tokens: BTreeSet, +} + +impl MasterCatalog { + /// Parses the observed master names of one class. + /// + /// Names arrive in whatever order the book returned them; the index is + /// ordered, so a report does not depend on that order. + pub fn new(class: MasterClass, names: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut entries = Vec::new(); + let mut by_name = BTreeMap::new(); + for name in names { + if entries.len() >= MAX_CATALOG_ENTRIES { + return Err(MasterBindingError::CatalogTooLarge); + } + let name = validated_name(name.as_ref())?; + if by_name.contains_key(&name) { + return Err(MasterBindingError::CatalogDuplicateName); + } + by_name.insert(name.clone(), entries.len()); + let key = comparison_key(&name); + entries.push(CatalogEntry { + identifiers: extract_identifiers(&name), + tokens: tokens_of(&key), + key, + name, + }); + } + if entries.is_empty() { + return Err(MasterBindingError::CatalogEmpty); + } + + let mut by_key: BTreeMap> = BTreeMap::new(); + let mut by_identifier: BTreeMap> = BTreeMap::new(); + let mut by_token: BTreeMap> = BTreeMap::new(); + for (index, entry) in entries.iter().enumerate() { + by_key.entry(entry.key.clone()).or_default().push(index); + for identifier in &entry.identifiers { + by_identifier + .entry(identifier.clone()) + .or_default() + .push(index); + } + for token in &entry.tokens { + by_token.entry(token.clone()).or_default().push(index); + } + } + + // A token carried by a large share of the catalog says nothing about + // which master is meant. The threshold is measured from the catalog + // rather than a built-in word list, so it carries no language or + // domain assumption. + let common_tokens = if entries.len() >= COMMON_TOKEN_MIN_CATALOG { + let limit = entries.len() * COMMON_TOKEN_PERCENT / 100; + by_token + .iter() + .filter(|(_, holders)| holders.len() > limit) + .map(|(token, _)| token.clone()) + .collect() + } else { + BTreeSet::new() + }; + + Ok(Self { + class, + entries, + by_name, + by_key, + by_identifier, + by_token, + common_tokens, + }) + } + + pub fn class(&self) -> MasterClass { + self.class + } + + /// Masters in this catalog. Never zero: an empty catalog is refused at + /// construction, so there is no emptiness for a caller to test. + pub fn master_count(&self) -> usize { + self.entries.len() + } + + pub fn names(&self) -> impl Iterator { + self.entries.iter().map(|entry| entry.name.as_str()) + } + + /// The observed name, when it is byte-identical to a current entry. + pub fn exact(&self, name: &str) -> Option<&str> { + self.by_name + .get(name) + .map(|index| self.entries[*index].name.as_str()) + } +} + +/// Binds every source entity against the catalog. +/// +/// The entity bound is enforced here rather than left to callers: a source +/// document is untrusted input, and an unbounded variant would be a rule +/// someone has to remember. Everything else was already decided by the two +/// constructors, so this is the only way binding can fail. +pub fn bind( + catalog: &MasterCatalog, + entities: &[SourceEntity], +) -> Result { + if entities.len() > MAX_SOURCE_ENTITIES { + return Err(MasterBindingError::TooManySourceEntities); + } + Ok(BindingReport { + class: catalog.class, + entities: entities + .iter() + .map(|entity| bind_one(catalog, entity)) + .collect(), + }) +} + +fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { + let exact = catalog.by_name.get(&entity.name).copied(); + + // Rule one: the identifier is the key, the name is a hint. A name + // comparison on a pair that carries a decisive identifier is not merely + // weaker evidence, it is actively misleading. + let mut identifier_matches = BTreeSet::new(); + let mut identifier_conflict = false; + for identifier in &entity.identifiers { + if let Some(holders) = catalog.by_identifier.get(identifier) { + if holders.len() > 1 { + identifier_conflict = true; + } + identifier_matches.extend(holders.iter().copied()); + } + } + + // An identifier shared by two masters, and an entity whose identifiers + // reach two masters, are the same refusal: the operator has a naming + // collision to see, and neither case licenses a choice. + let status = if identifier_conflict || identifier_matches.len() > 1 { + unresolved_status( + catalog, + entity, + UnboundReason::IdentifierConflict, + exact, + &identifier_matches, + ) + } else if let Some(matched) = identifier_matches.iter().copied().next() { + // An identifier pointing at one master while the name exactly names + // another is a disagreement between two strong signals; it is shown, + // not silently decided in the identifier's favour. + if exact.is_some_and(|index| index != matched) { + unresolved_status( + catalog, + entity, + UnboundReason::IdentifierNameConflict, + exact, + &identifier_matches, + ) + } else { + BindingStatus::Bound { + catalog_name: catalog.entries[matched].name.clone(), + basis: BindingBasis::Identifier, + } + } + } else if let Some(index) = exact { + BindingStatus::Bound { + catalog_name: catalog.entries[index].name.clone(), + basis: BindingBasis::ExactName, + } + } else { + match catalog.by_key.get(&entity.key).map(Vec::as_slice) { + Some([index]) => BindingStatus::Bound { + catalog_name: catalog.entries[*index].name.clone(), + basis: BindingBasis::NormalizedName, + }, + Some(_) => unresolved_status( + catalog, + entity, + UnboundReason::NameAmbiguous, + exact, + &identifier_matches, + ), + None => { + let candidates = collect_candidates(catalog, entity, &identifier_matches); + let reason = if candidates.is_empty() { + UnboundReason::NoCandidate + } else { + UnboundReason::NearMiss + }; + unresolved_from(entity, reason, candidates) + } + } + }; + + EntityBinding { + position: entity.position, + source_name: entity.name.clone(), + status, + } +} + +fn unresolved_status( + catalog: &MasterCatalog, + entity: &SourceEntity, + reason: UnboundReason, + exact: Option, + identifier_matches: &BTreeSet, +) -> BindingStatus { + let mut candidates = collect_candidates(catalog, entity, identifier_matches); + if let Some(index) = exact { + let name = catalog.entries[index].name.as_str(); + if !candidates.iter().any(|(candidate, _)| candidate == name) { + candidates.push((name.to_string(), CandidateRule::NormalizedEqual)); + } + } + unresolved_from(entity, reason, candidates) +} + +fn unresolved_from( + entity: &SourceEntity, + reason: UnboundReason, + candidates: Vec<(String, CandidateRule)>, +) -> BindingStatus { + let mut ordered = candidates; + ordered.sort_by(|left, right| { + left.1 + .rank() + .cmp(&right.1.rank()) + .then_with(|| left.0.cmp(&right.0)) + }); + let candidate_count = ordered.len(); + let candidates_truncated = candidate_count > MAX_CANDIDATES_PER_ENTITY; + let candidates = ordered + .into_iter() + .take(MAX_CANDIDATES_PER_ENTITY) + .map(|(catalog_name, rule)| Candidate { catalog_name, rule }) + .collect(); + let unresolved = Unresolved { + reason, + unresolved_identity: entity.identifiers.clone(), + candidates, + candidate_count, + candidates_truncated, + }; + if matches!(reason, UnboundReason::NoCandidate) { + BindingStatus::Unmatched(unresolved) + } else { + BindingStatus::Ambiguous(unresolved) + } +} + +/// Produces every defensible master, each labelled with the rule that surfaced +/// it. The strongest rule wins where several apply. Nothing here ranks by +/// similarity, and nothing here chooses. +fn collect_candidates( + catalog: &MasterCatalog, + entity: &SourceEntity, + identifier_matches: &BTreeSet, +) -> Vec<(String, CandidateRule)> { + let mut best: BTreeMap = BTreeMap::new(); + let mut offer = |index: usize, rule: CandidateRule| { + best.entry(index) + .and_modify(|current| { + if rule.rank() < current.rank() { + *current = rule; + } + }) + .or_insert(rule); + }; + + for index in identifier_matches { + offer(*index, CandidateRule::SharedIdentifier); + } + if let Some(holders) = catalog.by_key.get(&entity.key) { + for index in holders { + offer(*index, CandidateRule::NormalizedEqual); + } + } + if entity.key.chars().count() >= MIN_PREFIX_KEY_CHARS { + // The key index is ordered, so both prefix directions are range or + // point lookups rather than a scan of the whole catalog per entity. + for (key, holders) in catalog.by_key.range(entity.key.clone()..) { + if !key.starts_with(&entity.key) { + break; + } + if key == &entity.key { + continue; + } + for index in holders { + offer(*index, CandidateRule::CatalogPrefix); + } + } + for split in MIN_PREFIX_KEY_CHARS..entity.key.len() { + if !entity.key.is_char_boundary(split) { + continue; + } + let prefix = &entity.key[..split]; + if prefix.chars().count() < MIN_PREFIX_KEY_CHARS { + continue; + } + if let Some(holders) = catalog.by_key.get(prefix) { + for index in holders { + offer(*index, CandidateRule::SourcePrefix); + } + } + } + } + for token in tokens_of(&entity.key) { + if catalog.common_tokens.contains(&token) { + continue; + } + if let Some(holders) = catalog.by_token.get(&token) { + for index in holders { + offer(*index, CandidateRule::SharedToken); + } + } + } + + best.into_iter() + .map(|(index, rule)| (catalog.entries[index].name.clone(), rule)) + .collect() +} + +fn validated_name(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(MasterBindingError::NameBlank); + } + if value.chars().any(char::is_control) { + return Err(MasterBindingError::NameUnsafe); + } + if value.chars().count() > MAX_NAME_CHARS { + return Err(MasterBindingError::NameTooLong); + } + Ok(value.to_string()) +} + +/// Folds the punctuation an operator happened to type: NFC-equivalent dash and +/// quote variants become ASCII, case is lowered, whitespace runs collapse. +/// Nothing else is folded — no stemming, no transliteration, no vowel removal. +fn comparison_key(value: &str) -> String { + value + .nfc() + .flat_map(|character| match character { + '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}' + | '\u{2212}' => vec!['-'], + '\u{2018}' | '\u{2019}' | '\u{201a}' | '\u{201b}' => vec!['\''], + '\u{201c}' | '\u{201d}' | '\u{201e}' | '\u{201f}' => vec!['"'], + other => other.to_lowercase().collect(), + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn tokens_of(key: &str) -> BTreeSet { + key.split(|character: char| !character.is_alphanumeric()) + .filter(|token| token.chars().count() >= MIN_TOKEN_CHARS) + .map(str::to_string) + .collect() +} + +/// Extracts every identifier a name carries, in canonical form. +/// +/// A numeric run may hold `-` and `/` internally, so a punctuated account +/// number and a plain one agree; it may not hold spaces, so separated digit +/// groups fail closed to a near-miss rather than fusing into a false +/// identifier. +fn extract_identifiers(value: &str) -> Vec { + let mut identifiers = BTreeSet::new(); + for run in value.split(|character: char| { + !(character.is_ascii_digit() || character == '-' || character == '/') + }) { + let digits = run.chars().filter(char::is_ascii_digit).collect::(); + if digits.len() >= MIN_NUMERIC_IDENTIFIER_DIGITS && !is_plausible_date(&digits) { + identifiers.insert(Identifier { + kind: IdentifierKind::Numeric, + value: digits, + }); + } + } + for token in value.split(char::is_whitespace) { + let canonical = token + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .map(|character| character.to_ascii_uppercase()) + .collect::(); + let digits = canonical.chars().filter(char::is_ascii_digit).count(); + let letters = canonical.chars().filter(char::is_ascii_alphabetic).count(); + if canonical.len() >= MIN_CODE_IDENTIFIER_CHARS + && digits >= MIN_CODE_IDENTIFIER_DIGITS + && letters >= 1 + { + identifiers.insert(Identifier { + kind: IdentifierKind::Code, + value: canonical, + }); + } + } + let mut identifiers = identifiers.into_iter().collect::>(); + identifiers.truncate(MAX_IDENTIFIERS_PER_NAME); + identifiers +} + +/// An eight-digit run that reads as a calendar date is a date. Excluding it +/// costs a near-miss on an account number that happens to look like one, and +/// prevents a period label binding two unrelated masters together. +fn is_plausible_date(digits: &str) -> bool { + if digits.len() != 8 { + return false; + } + let number = |range: std::ops::Range| digits[range].parse::().unwrap_or(0); + let (year, month, day) = (number(0..4), number(4..6), number(6..8)); + (1900..=2199).contains(&year) && (1..=12).contains(&month) && (1..=31).contains(&day) +} + +#[cfg(test)] +#[path = "master_binding_tests.rs"] +mod tests; diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs new file mode 100644 index 00000000..fbaedf54 --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -0,0 +1,507 @@ +//! Every name, code and number here is fabricated from a placeholder +//! alphabet — Greek-letter party names, `PH-` stock codes, and numbers drawn +//! from the `55500000xx` placeholder block. Nothing is edited down from an +//! observed book, and none of it is evidence about any Tally instance: these +//! tests establish the behaviour of the binding rules only. + +use super::*; + +fn ledgers(names: &[&str]) -> MasterCatalog { + MasterCatalog::new(MasterClass::Ledger, names).expect("fabricated catalog is valid") +} + +fn entity(name: &str) -> SourceEntity { + SourceEntity::new(1, name).expect("fabricated source name is valid") +} + +fn bound(catalog: &MasterCatalog, entities: &[SourceEntity]) -> BindingReport { + bind(catalog, entities).expect("fabricated entity list is within bounds") +} + +fn bind_one_name(catalog: &MasterCatalog, name: &str) -> EntityBinding { + bound(catalog, &[entity(name)]) + .entities() + .first() + .cloned() + .expect("one entity in, one binding out") +} + +fn candidate_names(binding: &EntityBinding) -> Vec<&str> { + binding + .unresolved() + .expect("binding did not resolve") + .candidates + .iter() + .map(|candidate| candidate.catalog_name.as_str()) + .collect() +} + +fn reason(binding: &EntityBinding) -> UnboundReason { + binding + .unresolved() + .expect("binding did not resolve") + .reason +} + +// --- inputs are valid by construction ------------------------------------- + +#[test] +fn an_unread_book_is_an_error_not_an_empty_report() { + // Binding against a book nobody read is the failure this contract exists + // to prevent, so it cannot be expressed as "everything is missing". + let empty: [&str; 0] = []; + assert_eq!( + MasterCatalog::new(MasterClass::Ledger, empty), + Err(MasterBindingError::CatalogEmpty) + ); +} + +#[test] +fn a_duplicate_master_name_refuses_the_catalog() { + assert_eq!( + MasterCatalog::new(MasterClass::Ledger, ["Alpha Traders", "Alpha Traders"]), + Err(MasterBindingError::CatalogDuplicateName) + ); +} + +#[test] +fn unusable_names_are_refused_at_the_boundary() { + assert_eq!( + MasterCatalog::new(MasterClass::Ledger, [" "]), + Err(MasterBindingError::NameBlank) + ); + assert_eq!( + MasterCatalog::new(MasterClass::Ledger, ["Alpha\u{7}Traders"]), + Err(MasterBindingError::NameUnsafe) + ); + let long = "A".repeat(MAX_NAME_CHARS + 1); + assert_eq!( + MasterCatalog::new(MasterClass::Ledger, [long.as_str()]), + Err(MasterBindingError::NameTooLong) + ); + assert_eq!(SourceEntity::new(0, ""), Err(MasterBindingError::NameBlank)); +} + +#[test] +fn an_identifier_hint_that_yields_nothing_is_refused_rather_than_ignored() { + assert_eq!( + SourceEntity::with_identifier_hints(0, "Alpha Traders", ["not-an-identifier"]), + Err(MasterBindingError::IdentifierHintUnusable) + ); + let entity = SourceEntity::with_identifier_hints(0, "Alpha Traders", ["5550000001"]) + .expect("a usable hint is accepted"); + assert_eq!( + entity.identifiers(), + [Identifier { + kind: IdentifierKind::Numeric, + value: "5550000001".to_string(), + }] + ); +} + +#[test] +fn the_source_entity_bound_is_enforced_where_a_document_is_unbounded() { + let catalog = ledgers(&["Alpha Traders"]); + let entities = (0..=MAX_SOURCE_ENTITIES) + .map(|position| SourceEntity::new(position, "Alpha Traders").expect("valid")) + .collect::>(); + assert_eq!( + bind(&catalog, &entities), + Err(MasterBindingError::TooManySourceEntities) + ); +} + +// --- what binds ------------------------------------------------------------ + +#[test] +fn an_exact_name_binds() { + let catalog = ledgers(&["Alpha Traders", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Alpha Traders"); + assert_eq!( + binding.status, + BindingStatus::Bound { + catalog_name: "Alpha Traders".to_string(), + basis: BindingBasis::ExactName, + } + ); +} + +#[test] +fn case_whitespace_and_dash_style_do_not_defeat_a_bind() { + let catalog = ledgers(&["Alpha \u{2013} Traders", "Beta Supply"]); + let binding = bind_one_name(&catalog, " alpha - TRADERS "); + assert_eq!( + binding.status, + BindingStatus::Bound { + catalog_name: "Alpha \u{2013} Traders".to_string(), + basis: BindingBasis::NormalizedName, + } + ); +} + +#[test] +fn an_embedded_identifier_beats_three_wrong_name_candidates() { + // The engagement case, fabricated: the source names the party one way, the + // ledger another, and the only thing that agrees is the number the + // operator buried in the ledger name. Name matching offers three wrong + // people; the identifier decides. + let catalog = ledgers(&[ + "GAMMA (5550000001)", + "GAMMA ALPHA", + "GAMMA BETA", + "GAMMA DELTA", + ]); + let source = + SourceEntity::with_identifier_hints(3, "GAMMA. EPSILON", ["5550000001"]).expect("valid"); + let report = bound(&catalog, &[source]); + assert_eq!( + report.entities()[0].status, + BindingStatus::Bound { + catalog_name: "GAMMA (5550000001)".to_string(), + basis: BindingBasis::Identifier, + } + ); +} + +#[test] +fn an_identifier_inside_both_names_binds_without_a_hint() { + let catalog = ledgers(&["GAMMA (5550000001)", "GAMMA ALPHA"]); + let binding = bind_one_name(&catalog, "5550000001 GAMMA EPSILON"); + assert_eq!(binding.bound_name(), Some("GAMMA (5550000001)")); +} + +#[test] +fn a_punctuated_stock_code_binds_to_its_unpunctuated_form() { + let catalog = MasterCatalog::new( + MasterClass::StockItem, + ["PH-01A-B00", "PH-02A-B00", "Labour Placeholder"], + ) + .expect("valid"); + let binding = bound(&catalog, &[entity("PH01AB00")]) + .entities() + .first() + .cloned() + .expect("one binding"); + assert_eq!( + binding.status, + BindingStatus::Bound { + catalog_name: "PH-01A-B00".to_string(), + basis: BindingBasis::Identifier, + } + ); +} + +// --- what refuses to bind -------------------------------------------------- + +#[test] +fn one_identifier_carried_by_two_masters_is_ambiguous_never_a_bind() { + let catalog = ledgers(&["ALPHA (5550000001)", "BETA (5550000001)", "GAMMA Supply"]); + let binding = bind_one_name(&catalog, "PARTY 5550000001"); + assert_eq!(reason(&binding), UnboundReason::IdentifierConflict); + assert_eq!( + candidate_names(&binding), + ["ALPHA (5550000001)", "BETA (5550000001)"] + ); +} + +#[test] +fn an_identifier_and_an_exact_name_pointing_apart_is_shown_not_decided() { + let catalog = ledgers(&["ALPHA (5550000001)", "BETA Supply"]); + let source = + SourceEntity::with_identifier_hints(0, "BETA Supply", ["5550000001"]).expect("valid"); + let report = bound(&catalog, &[source]); + let binding = &report.entities()[0]; + assert_eq!(reason(binding), UnboundReason::IdentifierNameConflict); + assert!(candidate_names(binding).contains(&"ALPHA (5550000001)")); + assert!(candidate_names(binding).contains(&"BETA Supply")); +} + +#[test] +fn near_duplicate_masters_produce_candidates_and_choose_none() { + // Three masters differing by one character and word order. The operator + // decides; the module only shows the field. + let catalog = ledgers(&["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA", "Beta Supply"]); + let binding = bind_one_name(&catalog, "ALPHA"); + assert_eq!(reason(&binding), UnboundReason::NearMiss); + assert_eq!( + candidate_names(&binding), + ["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA"] + ); + let unresolved = binding.unresolved().expect("unbound"); + assert_eq!( + unresolved + .candidates + .iter() + .map(|candidate| candidate.rule) + .collect::>(), + [ + CandidateRule::CatalogPrefix, + CandidateRule::CatalogPrefix, + CandidateRule::SharedToken + ] + ); + assert_eq!(unresolved.candidate_count, 3); + assert!(!unresolved.candidates_truncated); +} + +#[test] +fn a_single_candidate_still_does_not_bind() { + // Four near-misses of ledgers that already existed rejected 61 vouchers. + // Uniqueness of a guess is not evidence. + let catalog = ledgers(&["ALPHA TRADING COMPANY", "Beta Supply"]); + let binding = bind_one_name(&catalog, "ALPHA TRADING COMP"); + assert_eq!(reason(&binding), UnboundReason::NearMiss); + assert_eq!(candidate_names(&binding), ["ALPHA TRADING COMPANY"]); + assert_eq!(binding.bound_name(), None); +} + +#[test] +fn a_truncated_source_name_surfaces_the_longer_master() { + let catalog = ledgers(&["DELTA WHOLESALE PLACEHOLDER", "Beta Supply"]); + let binding = bind_one_name(&catalog, "DELTA WHOLESALE PL"); + assert_eq!( + binding + .unresolved() + .expect("unbound") + .candidates + .first() + .map(|candidate| candidate.rule), + Some(CandidateRule::CatalogPrefix) + ); +} + +#[test] +fn a_source_name_extending_a_master_surfaces_the_shorter_master() { + let catalog = ledgers(&["DELTA WHOLESALE", "Beta Supply"]); + let binding = bind_one_name(&catalog, "DELTA WHOLESALE PLACEHOLDER BRANCH"); + let unresolved = binding.unresolved().expect("unbound"); + assert!(unresolved + .candidates + .iter() + .any(|candidate| candidate.rule == CandidateRule::SourcePrefix + && candidate.catalog_name == "DELTA WHOLESALE")); +} + +#[test] +fn nothing_defensible_is_unmatched_with_no_candidate() { + let catalog = ledgers(&["Alpha Traders", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Zeta Placeholder"); + assert!(matches!(binding.status, BindingStatus::Unmatched(_))); + assert_eq!(reason(&binding), UnboundReason::NoCandidate); + assert!(candidate_names(&binding).is_empty()); + assert_eq!( + reason(&binding).safe_reason_code(), + "master_binding_no_candidate" + ); +} + +// --- the identifier rules fail closed -------------------------------------- + +#[test] +fn a_date_shaped_run_is_not_an_identifier() { + // Two unrelated period-labelled masters must not fuse on their period. + let catalog = ledgers(&["ALPHA 2026-09-10", "BETA 2026-09-10", "Gamma Supply"]); + let binding = bind_one_name(&catalog, "DELTA 2026-09-10"); + assert!(binding.unresolved().is_some()); + assert!(binding + .unresolved() + .expect("unbound") + .unresolved_identity + .is_empty()); +} + +#[test] +fn a_short_digit_run_is_not_an_identifier() { + // A masked last-four cannot bind two accounts that share four digits. + let catalog = ledgers(&["ALPHA BANK CA 2129", "BETA BANK CA 2129"]); + let binding = bind_one_name(&catalog, "GAMMA BANK CA 2129"); + assert!(binding + .unresolved() + .expect("unbound") + .unresolved_identity + .is_empty()); +} + +#[test] +fn separated_digit_groups_do_not_fuse_into_an_identifier() { + let entity = entity("ALPHA 5550 0000 01"); + assert!(entity.identifiers().is_empty()); +} + +// --- candidate discipline -------------------------------------------------- + +#[test] +fn a_catalog_wide_token_stops_discriminating() { + let mut names = (0..40) + .map(|index| format!("PLACEHOLDER UNIT {index:02}")) + .collect::>(); + names.push("ALPHA PLACEHOLDER TRADERS".to_string()); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + // "placeholder" is carried by every entry, so it may not pull all 41 in. + let binding = bind_one_name(&catalog, "PLACEHOLDER ZETA"); + assert!(binding.unresolved().expect("unbound").candidate_count <= 1); +} + +#[test] +fn candidates_are_capped_with_the_true_count_retained() { + let names = (0..MAX_CANDIDATES_PER_ENTITY + 5) + .map(|index| format!("ALPHAGROUP UNIT {index:02}")) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let binding = bind_one_name(&catalog, "ALPHAGROUP"); + let unresolved = binding.unresolved().expect("unbound"); + assert_eq!(unresolved.candidates.len(), MAX_CANDIDATES_PER_ENTITY); + assert_eq!(unresolved.candidate_count, MAX_CANDIDATES_PER_ENTITY + 5); + assert!(unresolved.candidates_truncated); +} + +#[test] +fn candidate_order_is_rule_then_name_and_never_a_ranking() { + let catalog = ledgers(&[ + "ALPHA (5550000002)", + "ALPHA WHOLESALE", + "ZETA ALPHA STORE", + "ALPHA (5550000003)", + ]); + let source = SourceEntity::with_identifier_hints(0, "ALPHA", ["5550000002", "5550000003"]) + .expect("valid"); + let report = bound(&catalog, &[source]); + let unresolved = report.entities()[0].unresolved().expect("unbound"); + assert_eq!( + unresolved + .candidates + .iter() + .map(|candidate| (candidate.catalog_name.as_str(), candidate.rule)) + .collect::>(), + [ + ("ALPHA (5550000002)", CandidateRule::SharedIdentifier), + ("ALPHA (5550000003)", CandidateRule::SharedIdentifier), + ("ALPHA WHOLESALE", CandidateRule::CatalogPrefix), + ("ZETA ALPHA STORE", CandidateRule::SharedToken), + ] + ); +} + +#[test] +fn the_report_does_not_depend_on_the_order_the_book_returned() { + let forward = ledgers(&["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA", "Beta Supply"]); + let reversed = ledgers(&["Beta Supply", "SALES - ALPHA", "ALPHA SALES", "ALPHA SALE"]); + let entities = [entity("ALPHA"), entity("Beta Supply")]; + assert_eq!( + bound(&forward, &entities).entities(), + bound(&reversed, &entities).entities() + ); +} + +// --- the unbound list is the product --------------------------------------- + +#[test] +fn totals_reconcile_the_run() { + let catalog = ledgers(&["Alpha Traders", "ALPHA SALE", "ALPHA SALES"]); + let entities = [ + entity("Alpha Traders"), + entity("ALPHA"), + entity("Zeta Placeholder"), + ]; + let report = bound(&catalog, &entities); + let totals = report.totals(); + assert_eq!(totals.requested, 3); + assert_eq!(totals.bound, 1); + assert_eq!(totals.ambiguous, 1); + assert_eq!(totals.unmatched, 1); + assert_eq!(totals.requested, totals.bound + totals.unbound); + assert_eq!(totals.unbound, totals.ambiguous + totals.unmatched); + assert_eq!(report.bound().count(), totals.bound); + assert_eq!(report.unbound().count(), totals.unbound); + assert_eq!(report.class(), MasterClass::Ledger); +} + +#[test] +fn an_unbound_entry_retains_the_identity_that_will_reallocate_it() { + let catalog = ledgers(&["ALPHA (5550000001)", "BETA (5550000001)"]); + let binding = bind_one_name(&catalog, "PARTY 5550000001"); + assert_eq!( + binding.unresolved().expect("unbound").unresolved_identity, + [Identifier { + kind: IdentifierKind::Numeric, + value: "5550000001".to_string(), + }] + ); +} + +// --- the fallback is constructed, never inferred --------------------------- + +#[test] +fn an_ambiguous_entity_parks_against_a_verified_fallback() { + let catalog = ledgers(&[ + "ALPHA (5550000001)", + "BETA (5550000001)", + "Suspense Placeholder", + ]); + let binding = bind_one_name(&catalog, "PARTY 5550000001"); + let fallback = FallbackBinding::assign(&binding, &catalog, "Suspense Placeholder") + .expect("an unbound entity may be parked"); + assert_eq!(fallback.fallback_name(), "Suspense Placeholder"); + assert_eq!(fallback.source_name(), "PARTY 5550000001"); + assert_eq!(fallback.reason(), UnboundReason::IdentifierConflict); + assert_eq!(fallback.retained_tag(), "numeric:5550000001"); +} + +#[test] +fn a_bound_entity_cannot_be_parked() { + let catalog = ledgers(&["Alpha Traders", "Suspense Placeholder"]); + let binding = bind_one_name(&catalog, "Alpha Traders"); + assert_eq!( + FallbackBinding::assign(&binding, &catalog, "Suspense Placeholder"), + Err(MasterBindingError::FallbackNotInCatalog) + ); +} + +#[test] +fn a_fallback_master_that_does_not_exist_is_refused() { + // A suspense ledger that was never created is how one batch was lost. + let catalog = ledgers(&["Alpha Traders", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Zeta Placeholder"); + assert_eq!( + FallbackBinding::assign(&binding, &catalog, "Suspense Placeholder"), + Err(MasterBindingError::FallbackNotInCatalog) + ); +} + +// --- the vocabulary is stable ---------------------------------------------- + +#[test] +fn reason_and_error_codes_are_stable_and_safe() { + assert_eq!( + UnboundReason::IdentifierConflict.safe_reason_code(), + "master_binding_identifier_conflict" + ); + assert_eq!( + UnboundReason::IdentifierNameConflict.safe_reason_code(), + "master_binding_identifier_name_conflict" + ); + assert_eq!( + UnboundReason::NameAmbiguous.safe_reason_code(), + "master_binding_name_ambiguous" + ); + assert_eq!( + UnboundReason::NearMiss.safe_reason_code(), + "master_binding_near_miss" + ); + assert_eq!( + MasterBindingError::CatalogEmpty.safe_reason_code(), + "master_catalog_empty" + ); +} + +#[test] +fn a_bound_status_serializes_without_a_score_field() { + let catalog = ledgers(&["Alpha Traders"]); + let binding = bind_one_name(&catalog, "Alpha Traders"); + let json = serde_json::to_value(&binding).expect("serializable"); + assert_eq!(json["status"], "bound"); + assert_eq!(json["catalog_name"], "Alpha Traders"); + assert_eq!(json["basis"], "exact_name"); + assert!(json.get("score").is_none()); + assert!(json.get("confidence").is_none()); +} diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index cbf12a1b..c3e6935d 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -148,7 +148,7 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: json!({"type":"object", "additionalProperties":false}), ), "validate_masters" => ( - "Validate 1–100 nonblank ledger names (at most 1024 characters each). Near-miss suggestions are bounded to 25 names and 8192 UTF-8 bytes per requested name, with total count and truncation reported.", + "Bind 1–100 nonblank ledger names (at most 1024 characters each) against the live catalogue. An identifier embedded in a master name is matched before the name itself. `match_state` is exact, normalized, identifier, near_miss or missing; only `exact` is admitted by build_import_xml, and a bound row alone carries `exact_live_spelling`. A near-miss is never resolved: it returns candidates with the rule that surfaced each, bounded to 25 names and 8192 UTF-8 bytes per requested name, with total count and truncation reported. There is no ranking and no score.", json!({"type":"object", "additionalProperties":false, "required":["company_guid","ledgers"], "properties":{"company_guid":{"type":"string"},"ledgers":{"type":"array","minItems":1,"maxItems":agent_import::MAX_MASTER_NAMES,"items":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"}}}}), ), "build_import_xml" => ( diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index 7d4af8c7..f6eb39c7 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -8,6 +8,9 @@ use crate::tally::standard_ledger_catalog::{ admit_standard_ledger_catalog_request, parse_standard_ledger_catalog_response, render_standard_ledger_catalog_request, }; +use bridge_tally_core::master_binding::{ + self, BindingBasis, BindingStatus, EntityBinding, MasterCatalog, MasterClass, SourceEntity, +}; use bridge_tally_core::ExactDecimal; use bridge_tally_protocol::outstandings_shared::DateBoundaryProfile; use chrono::{SecondsFormat, Utc}; @@ -39,7 +42,6 @@ mod persistence; #[path = "agent_import_post.rs"] mod post; use std::path::{Path, PathBuf}; -use unicode_normalization::UnicodeNormalization; use uuid::Uuid; struct ImportProfileObservation { @@ -279,10 +281,11 @@ impl Server { .read_ledger_catalogue(&identity, &company.name) .await .map_err(|failure| failure.with_prior_evidence(identity_evidence.clone()))?; - let report = ledgers - .into_iter() - .map(|wanted| master_match(wanted, &catalogue)) - .collect::>(); + let report = master_report( + &ledgers.into_iter().map(str::to_string).collect::>(), + &catalogue, + ) + .map_err(|code| ToolFailure::from(code).with_prior_evidence(identity_evidence.clone()))?; let hash = sha256_json(&catalogue); Ok(ToolOutcome { payload: json!({"company": company_json(&company, std::slice::from_ref(&company)), "result": {"masters": report, "catalogue_evidence_sha256": hash}}), @@ -369,7 +372,7 @@ impl Server { let (catalogue, catalogue_evidence) = self.read_ledger_catalogue(&identity, &company.name).await?; accumulated = combine_evidence(accumulated.clone(), catalogue_evidence.clone()); - let report = masters_for_payload(&payload, &catalogue); + let report = masters_for_payload(&payload, &catalogue)?; if report.iter().any(|value| value["match_state"] != "exact") { return Ok(ToolOutcome { payload: json!({"company": company_json(&company, std::slice::from_ref(&company)), "result": { @@ -1109,11 +1112,11 @@ fn totals(vouchers: &[ImportVoucher]) -> Result<(ExactDecimal, ExactDecimal), St Ok((debit, credit)) } -fn masters_for_payload(payload: &ImportPayload, catalogue: &[String]) -> Vec { - requested_ledger_names(payload) - .into_iter() - .map(|name| master_match(&name, catalogue)) - .collect() +fn masters_for_payload( + payload: &ImportPayload, + catalogue: &[String], +) -> Result, String> { + master_report(&requested_ledger_names(payload), catalogue) } fn requested_ledger_names(payload: &ImportPayload) -> Vec { @@ -1127,53 +1130,93 @@ fn requested_ledger_names(payload: &ImportPayload) -> Vec { .collect() } -fn master_match(wanted: &str, catalogue: &[String]) -> Value { - if catalogue.iter().any(|name| name == wanted) { - return json!({"requested": party_name(wanted), "match_state":"exact", "exact_live_spelling":party_name(wanted)}); - } - let key = master_key(wanted); - let candidates = catalogue +/// Bounds the candidate names one unbound entity may copy into a tool result. +/// The binding contract bounds the candidate *count*; this bounds their bytes, +/// which is an egress concern rather than a matching one. +const MAX_CANDIDATE_RESULT_BYTES: usize = 8_192; + +/// Binds requested ledger names against the observed catalogue. +/// +/// The rules live in `bridge_tally_core::master_binding` so this tool and the +/// desktop preparation screen cannot drift apart; see +/// `docs/adr/0016-master-binding-authority.md`. This function only renders the +/// report, and it never promotes a candidate into a spelling. +fn master_report(requested: &[String], catalogue: &[String]) -> Result, String> { + let catalog = MasterCatalog::new(MasterClass::Ledger, catalogue) + .map_err(|error| error.safe_reason_code().to_string())?; + let entities = requested .iter() - .filter(|name| { - let candidate = master_key(name); - candidate == key || candidate.starts_with(&key) || key.starts_with(&candidate) - }) - .collect::>(); - let candidate_count = candidates.len(); - if candidate_count == 0 { - json!({"requested":party_name(wanted),"match_state":"missing"}) - } else { - let mut bytes = 0_usize; - let candidates = candidates - .into_iter() - .take(25) - .take_while(|name| { - bytes = bytes.saturating_add(name.len()); - bytes <= 8192 + .enumerate() + .map(|(position, name)| SourceEntity::new(position, name)) + .collect::, _>>() + .map_err(|error| error.safe_reason_code().to_string())?; + let report = master_binding::bind(&catalog, &entities) + .map_err(|error| error.safe_reason_code().to_string())?; + Ok(report.entities().iter().map(master_match_json).collect()) +} + +fn master_match_json(binding: &EntityBinding) -> Value { + let requested = party_name(binding.source_name.clone()); + match &binding.status { + BindingStatus::Bound { + catalog_name, + basis, + } => { + // Only byte-exact equality may be reported as `exact`: the import + // file carries the name verbatim, and build_import_xml admits + // nothing else. + let match_state = match basis { + BindingBasis::ExactName => "exact", + BindingBasis::NormalizedName => "normalized", + BindingBasis::Identifier => "identifier", + }; + json!({ + "requested": requested, + "match_state": match_state, + "exact_live_spelling": party_name(catalog_name.clone()), }) - .map(|name| party_name(name.clone())) - .collect::>(); - json!({"requested":party_name(wanted),"match_state":"near_miss", - "exact_live_spelling":candidates.first(),"candidate_count":candidate_count, - "candidates_truncated":candidates.len() < candidate_count,"candidates":candidates}) + } + BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved) => { + let mut bytes = 0_usize; + let candidates = unresolved + .candidates + .iter() + .take_while(|candidate| { + bytes = bytes.saturating_add(candidate.catalog_name.len()); + bytes <= MAX_CANDIDATE_RESULT_BYTES + }) + .map(|candidate| { + json!({ + "name": party_name(candidate.catalog_name.clone()), + "rule": candidate.rule, + }) + }) + .collect::>(); + // No `exact_live_spelling`. Naming one candidate as the live + // spelling is the auto-resolution that rejected a batch once. + json!({ + "requested": requested, + "match_state": match binding.status { + BindingStatus::Unmatched(_) => "missing", + _ => "near_miss", + }, + "reason": unresolved.reason.safe_reason_code(), + "candidate_count": unresolved.candidate_count, + "candidates_truncated": candidates.len() < unresolved.candidate_count, + "candidates": candidates, + "unresolved_identity": unresolved + .unresolved_identity + .iter() + .map(|identifier| json!({ + "kind": identifier.kind, + "value": party_name(identifier.value.clone()), + })) + .collect::>(), + }) + } } } -fn master_key(value: &str) -> String { - value - .nfc() - .flat_map(|character| match character { - '–' | '—' | '−' | '‐' | '‑' => "-".chars().collect::>(), - '‘' | '’' | '‚' | '‛' => "'".chars().collect(), - '“' | '”' | '„' | '‟' => "\"".chars().collect(), - other => other.to_lowercase().collect(), - }) - .collect::() - .split_whitespace() - .collect::>() - .join(" ") -} - fn render_import_xml(company: &str, vouchers: &[ImportVoucher], batch_id: &str) -> String { let messages = vouchers .iter() diff --git a/src-tauri/src/agent_import_post.rs b/src-tauri/src/agent_import_post.rs index c8d093a5..58b0a0c1 100644 --- a/src-tauri/src/agent_import_post.rs +++ b/src-tauri/src/agent_import_post.rs @@ -146,7 +146,7 @@ impl Server { .read_import_ledger_catalogue(&identity, &company.name) .await?; accumulated = combine_evidence(accumulated.clone(), evidence); - if masters_for_payload(&payload, &catalogue) + if masters_for_payload(&payload, &catalogue)? .iter() .any(|item| item["match_state"] != "exact") { diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index 977b310e..66021e83 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -275,29 +275,39 @@ fn schema_balance_matcher_rendering_and_ledger_append_are_fail_closed() { validate_payload(&unbalanced), Err("voucher_not_balanced".to_string()) ); + // Case, whitespace style, dash style and quote style name the same live + // ledger, so they bind and report its exact spelling. Only byte equality + // is `exact`, which is what build_import_xml admits. + for wanted in ["bank ", "bank", "BANK"] { + let matched = one_master_match(wanted, &["Bank"]); + assert_eq!(matched["match_state"], "normalized"); + assert_eq!( + matched["exact_live_spelling"][super::super::PARTY_NAME_MARKER], + "Bank" + ); + } assert_eq!( - master_match("bank ", &["Bank".to_string()])["match_state"], - "near_miss" - ); - assert_eq!( - master_match("bank", &["Bank".to_string()])["match_state"], - "near_miss" - ); - assert_eq!( - master_match("A\u{a0}B", &["A B".to_string()])["match_state"], - "near_miss" + one_master_match("A\u{a0}B", &["A B"])["match_state"], + "normalized" ); assert_eq!( - master_match("Fees-Admin", &["Fees–Admin".to_string()])["match_state"], - "near_miss" + one_master_match("Fees-Admin", &["Fees–Admin"])["match_state"], + "normalized" ); assert_eq!( - master_match("Bob's", &["Bob’s".to_string()])["match_state"], - "near_miss" + one_master_match("Bob's", &["Bob’s"])["match_state"], + "normalized" ); + assert_eq!(one_master_match("Bank", &["Bank"])["match_state"], "exact"); + // A shorter name that a live ledger extends is a near-miss with one + // candidate, and one candidate is still not a decision. + let near = one_master_match("Bank", &["Bank Charges"]); + assert_eq!(near["match_state"], "near_miss"); + assert_eq!(near["reason"], "master_binding_near_miss"); + assert!(near.get("exact_live_spelling").is_none()); assert_eq!( - master_match("Bank", &["Bank Charges".to_string()])["match_state"], - "near_miss" + near["candidates"][0]["name"][super::super::PARTY_NAME_MARKER], + "Bank Charges" ); let xml = render_import_xml("Book & Co", &input.vouchers, "batch-render"); assert!(xml.starts_with("")); @@ -1478,22 +1488,36 @@ fn native_captured_import_readback_keeps_direct_amounts_and_padded_identifiers() } } +/// Binds one name against a fabricated catalogue and returns its rendered row. +fn one_master_match(wanted: &str, catalogue: &[&str]) -> Value { + let catalogue = catalogue + .iter() + .map(|name| (*name).to_string()) + .collect::>(); + master_report(&[wanted.to_string()], &catalogue) + .expect("fabricated catalogue binds") + .remove(0) +} + #[test] fn master_match_bounds_suggestions_before_copying_names_and_preserves_ambiguity() { let catalogue = (0..100) .map(|index| format!("Ledger {index:03}")) .collect::>(); - let matched = master_match("L", &catalogue); + let borrowed = catalogue.iter().map(String::as_str).collect::>(); + let matched = one_master_match("Ledger", &borrowed); assert_eq!(matched["match_state"], "near_miss"); assert_eq!(matched["candidate_count"], 100); assert_eq!(matched["candidates_truncated"], true); assert_eq!(matched["candidates"].as_array().unwrap().len(), 25); assert_eq!( - master_match("Ledger 099", &catalogue)["match_state"], + one_master_match("Ledger 099", &borrowed)["match_state"], "exact" ); + // A single pathological live name is bounded by bytes before it is copied + // into a result, and its true count is still reported. let huge = format!("Large{}", "x".repeat(8192)); - let limited = master_match("L", std::slice::from_ref(&huge)); + let limited = one_master_match("Large", &[huge.as_str()]); assert_eq!(limited["match_state"], "near_miss"); assert_eq!(limited["candidate_count"], 1); assert_eq!(limited["candidates_truncated"], true); @@ -1501,6 +1525,57 @@ fn master_match_bounds_suggestions_before_copying_names_and_preserves_ambiguity( assert!(!limited.to_string().contains(&huge)); } +#[test] +fn a_catalogue_that_was_never_read_refuses_instead_of_reporting_everything_missing() { + // "Nobody read the ledger list out of Tally first" is the recorded cause + // of the one failed engagement, so an empty catalogue must not look like + // an answer. P5: nothing-found and request-failed stay distinguishable. + assert_eq!( + master_report(&["Bank".to_string()], &[]), + Err("master_catalog_empty".to_string()) + ); +} + +#[test] +fn an_embedded_identifier_decides_where_the_name_offers_wrong_candidates() { + // Fabricated from a placeholder alphabet: the live ledger carries a number + // the operator typed into its name, and the requested name matches no live + // spelling. The number is the key; the name is a hint. + let matched = one_master_match( + "GAMMA. EPSILON 5550000001", + &["GAMMA (5550000001)", "GAMMA ALPHA", "GAMMA BETA"], + ); + assert_eq!(matched["match_state"], "identifier"); + assert_eq!( + matched["exact_live_spelling"][super::super::PARTY_NAME_MARKER], + "GAMMA (5550000001)" + ); +} + +#[test] +fn a_near_miss_never_names_a_live_spelling_and_retains_its_identity() { + let matched = one_master_match( + "PARTY 5550000001", + &["ALPHA (5550000001)", "BETA (5550000001)"], + ); + assert_eq!(matched["match_state"], "near_miss"); + assert_eq!(matched["reason"], "master_binding_identifier_conflict"); + assert!(matched.get("exact_live_spelling").is_none()); + assert_eq!(matched["candidate_count"], 2); + assert_eq!( + matched["unresolved_identity"][0]["value"][super::super::PARTY_NAME_MARKER], + "5550000001" + ); +} + +#[test] +fn nothing_defensible_is_reported_missing_with_no_candidate() { + let matched = one_master_match("Zeta Placeholder", &["Bank", "Cash"]); + assert_eq!(matched["match_state"], "missing"); + assert_eq!(matched["reason"], "master_binding_no_candidate"); + assert!(matched["candidates"].as_array().unwrap().is_empty()); +} + #[tokio::test] async fn import_bounds_distinct_ledger_names_before_tally_without_reducing_voucher_limit() { let mut repeated = payload(); diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index fbae0a5a..7505924e 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -8,6 +8,9 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use bridge_tally_core::master_binding::{ + self, BindingBasis, BindingStatus, MasterCatalog, MasterClass, SourceEntity, +}; use bridge_tally_protocol::{StandardLedgerCatalog, StandardLedgerCatalogBinding}; use crate::{ @@ -51,9 +54,31 @@ pub(crate) struct SourceDraftCatalogTargets { pub(crate) capture_id: String, pub(crate) source_sha256: String, pub(crate) targets: Vec, + pub(crate) bindings: Vec, pub(crate) evidence: SourceDraftCatalogEvidence, } +/// One source entry's deterministic binding against the capture, so an +/// operator sees the few relevant ledgers rather than the whole catalog. +/// +/// This narrows a list and grants nothing. `bound_target` names a live ledger +/// only where the rules in `bridge_tally_core::master_binding` decided it +/// outright; a near-miss carries candidates and no target. Applying any of +/// them still goes through the unchanged apply path, which rereads the catalog +/// and proves the selection is current — matching text remains never a +/// selected or approved target. +#[derive(Debug, Serialize)] +pub(crate) struct SourceDraftCatalogBinding { + pub(crate) row_position: usize, + pub(crate) entry_position: usize, + pub(crate) bound_target: Option, + pub(crate) bound_basis: Option, + pub(crate) unbound_reason: Option<&'static str>, + pub(crate) candidates: Vec, + pub(crate) candidate_count: usize, + pub(crate) candidates_truncated: bool, +} + #[derive(Debug, Serialize)] pub(crate) struct SourceDraftCatalogEvidence { pub(crate) request_sha256: String, @@ -100,6 +125,71 @@ pub(super) struct CatalogApplySnapshot { pub(super) catalog: StandardLedgerCatalog, } +/// Binds every source entry's observed ledger name against the captured +/// catalog. Advisory only: an empty or unusable capture narrows nothing rather +/// than failing the read the operator just performed, and every returned name +/// is still revalidated by the apply path before it can become a target. +fn source_entry_bindings( + source: &crate::source_draft_xml::ParsedSource, + targets: &[String], +) -> Vec { + let Ok(catalog) = MasterCatalog::new(MasterClass::Ledger, targets) else { + return Vec::new(); + }; + let mut located = Vec::new(); + let mut entities = Vec::new(); + for voucher in &source.vouchers { + for entry in &voucher.entries { + let Ok(entity) = SourceEntity::new(entities.len(), &entry.ledger) else { + continue; + }; + located.push((voucher.position, entry.position)); + entities.push(entity); + } + } + let Ok(report) = master_binding::bind(&catalog, &entities) else { + return Vec::new(); + }; + report + .entities() + .iter() + .zip(located) + .map( + |(binding, (row_position, entry_position))| match &binding.status { + BindingStatus::Bound { + catalog_name, + basis, + } => SourceDraftCatalogBinding { + row_position, + entry_position, + bound_target: Some(catalog_name.clone()), + bound_basis: Some(*basis), + unbound_reason: None, + candidates: Vec::new(), + candidate_count: 0, + candidates_truncated: false, + }, + BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved) => { + SourceDraftCatalogBinding { + row_position, + entry_position, + bound_target: None, + bound_basis: None, + unbound_reason: Some(unresolved.reason.safe_reason_code()), + candidates: unresolved + .candidates + .iter() + .map(|candidate| candidate.catalog_name.clone()) + .collect(), + candidate_count: unresolved.candidate_count, + candidates_truncated: unresolved.candidates_truncated, + } + } + }, + ) + .collect() +} + pub(super) fn require_current_catalog_binding( binding: &StandardLedgerCatalogBinding, fresh_body: &str, @@ -244,6 +334,7 @@ impl SourceDraftStore { return Err(error("source_draft_catalogue_invalidated")); } let targets = read.catalog.names().map(str::to_owned).collect::>(); + let bindings = source_entry_bindings(¤t.source, &targets); let capture = CatalogCapture { id: Uuid::new_v4(), draft_id: current.id, @@ -258,6 +349,7 @@ impl SourceDraftStore { capture_id: capture.id.to_string(), source_sha256: capture.source_sha256.clone(), targets, + bindings, evidence: SourceDraftCatalogEvidence { request_sha256: read.request_sha256, response_sha256: read.response_sha256, @@ -427,6 +519,65 @@ mod tests { Fixture, ProductStatus, ResponseFraming, ScenarioPlan, SequenceSimulator, WireEncoding, }; + /// Fabricated from a placeholder alphabet; nothing here is edited down + /// from an observed book. + fn fabricated_source() -> crate::source_draft_xml::ParsedSource { + parse_source_xml( + concat!( + "", + "20260901", + "alpha traders1", + "GAMMA. EPSILON 5550000001-1", + "Zeta Placeholder0", + "" + ) + .as_bytes(), + "source.xml".into(), + ) + .expect("fabricated source parses") + } + + #[test] + fn a_capture_narrows_each_source_entry_without_deciding_a_near_miss() { + let targets = [ + "Alpha Traders".to_string(), + "GAMMA (5550000001)".to_string(), + "GAMMA ALPHA".to_string(), + "Beta Supply".to_string(), + ]; + let bindings = source_entry_bindings(&fabricated_source(), &targets); + assert_eq!(bindings.len(), 3); + + // Case alone does not defeat a bind, and the live spelling is named. + assert_eq!(bindings[0].row_position, 1); + assert_eq!(bindings[0].entry_position, 1); + assert_eq!(bindings[0].bound_target.as_deref(), Some("Alpha Traders")); + assert_eq!(bindings[0].bound_basis, Some(BindingBasis::NormalizedName)); + + // The number the operator buried in the ledger name decides where the + // name offers a wrong candidate. + assert_eq!( + bindings[1].bound_target.as_deref(), + Some("GAMMA (5550000001)") + ); + assert_eq!(bindings[1].bound_basis, Some(BindingBasis::Identifier)); + + // Nothing defensible stays unbound with no target of any kind. + assert!(bindings[2].bound_target.is_none()); + assert_eq!( + bindings[2].unbound_reason, + Some("master_binding_no_candidate") + ); + assert!(bindings[2].candidates.is_empty()); + } + + #[test] + fn narrowing_is_advisory_and_never_fails_a_completed_capture() { + // An unusable capture narrows nothing rather than discarding a read the + // operator just performed. The apply path still owns every refusal. + assert!(source_entry_bindings(&fabricated_source(), &[]).is_empty()); + } + const CAPTURED_COMPANY: &str = "WR2 Unicode Lab"; const CAPTURED_GUID: &str = "61c6de69-1748-461c-ad3f-162cb949df9f"; diff --git a/src/source-draft-types.ts b/src/source-draft-types.ts index 3863a7f1..9f80539f 100644 --- a/src/source-draft-types.ts +++ b/src/source-draft-types.ts @@ -59,10 +59,26 @@ export type SourceDraftCompanyScope = { }; }; +/// One source entry's deterministic binding against the captured catalog. +/// Advisory: it narrows the target list and confers no authority. Applying a +/// name still goes through the unchanged assign path, which rereads the +/// catalog and proves the selection is current. +export type SourceDraftCatalogBinding = { + row_position: number; + entry_position: number; + bound_target: string | null; + bound_basis: "identifier" | "exact_name" | "normalized_name" | null; + unbound_reason: string | null; + candidates: string[]; + candidate_count: number; + candidates_truncated: boolean; +}; + export type SourceDraftCatalogTargets = { capture_id: string; source_sha256: string; targets: string[]; + bindings: SourceDraftCatalogBinding[]; evidence: { request_sha256: string; response_sha256: string; bytes: number; state: "complete" }; }; diff --git a/tools/Cargo.lock b/tools/Cargo.lock index 70443a14..e9765496 100644 --- a/tools/Cargo.lock +++ b/tools/Cargo.lock @@ -92,6 +92,7 @@ dependencies = [ "serde_json", "sha2", "thiserror", + "unicode-normalization", ] [[package]] @@ -1648,6 +1649,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" From 407b7829aecd3bc0a5f8b6d898efae080418e1f7 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Thu, 10 Sep 2026 22:13:02 +0530 Subject: [PATCH 02/75] Retain observed master names verbatim, and characterize the rules at scale Self-review found a real defect: a catalog name was trimmed at the boundary, so a bound row reported a spelling the book does not contain. The write gate compares byte-exact against Tally's own name, so that would have refused with no explanation. Observed names are now retained verbatim; only source names are trimmed. Names differing solely in surrounding whitespace are an ambiguity, not a refused catalog. Adds a characterization suite over one fabricated 200-master book with the recorded naming pathologies. The assertion that matters is that no entity binds to a master a human would not have chosen; the counts are pinned underneath so loosening a threshold has to move a number. The mutation sweep was checked against two positive controls rather than trusted for passing: resolving a near-miss to its first candidate trips it, and binding a lone candidate does not. Both results are recorded in the test, so it is read as "no mutation reaches the wrong master" and not as "no rule change can loosen binding". Co-Authored-By: Claude Opus 5 --- .../bridge-tally-core/src/master_binding.rs | 32 +- .../src/master_binding_tests.rs | 351 ++++++++++++++++++ 2 files changed, 377 insertions(+), 6 deletions(-) diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 88672867..5a70a923 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -67,6 +67,9 @@ pub enum MasterBindingError { #[error("master catalog was empty")] CatalogEmpty, /// Two masters carry byte-identical names, so a name cannot identify one. + /// Names differing only in surrounding whitespace are *not* duplicates — + /// they are retained verbatim and collide on the comparison key instead, + /// which surfaces them as an ambiguity rather than failing the read. #[error("master catalog carried a duplicate name")] CatalogDuplicateName, #[error("master catalog exceeded its bound")] @@ -426,7 +429,7 @@ impl SourceEntity { name: &str, hints: impl IntoIterator, ) -> Result { - let name = validated_name(name)?; + let name = validated_source_name(name)?; let mut identifiers = extract_identifiers(&name); for hint in hints { let extracted = extract_identifiers(hint); @@ -498,7 +501,7 @@ impl MasterCatalog { if entries.len() >= MAX_CATALOG_ENTRIES { return Err(MasterBindingError::CatalogTooLarge); } - let name = validated_name(name.as_ref())?; + let name = validated_catalog_name(name.as_ref())?; if by_name.contains_key(&name) { return Err(MasterBindingError::CatalogDuplicateName); } @@ -806,9 +809,26 @@ fn collect_candidates( .collect() } -fn validated_name(value: &str) -> Result { - let value = value.trim(); - if value.is_empty() { +/// An observed master name is retained **verbatim**. Surrounding whitespace is +/// part of what the book returned, and a caller that acts on a binding writes +/// this string back to Tally byte for byte; trimming it here would report a +/// spelling that does not exist and refuse at the write gate with no +/// explanation. The comparison key collapses whitespace anyway, so a source +/// name still matches across the difference. +fn validated_catalog_name(value: &str) -> Result { + validate_name_bounds(value)?; + Ok(value.to_string()) +} + +/// A source name is trimmed: leading and trailing whitespace is document noise +/// rather than an observation, and nothing is ever written back from it. +fn validated_source_name(value: &str) -> Result { + validate_name_bounds(value)?; + Ok(value.trim().to_string()) +} + +fn validate_name_bounds(value: &str) -> Result<(), MasterBindingError> { + if value.trim().is_empty() { return Err(MasterBindingError::NameBlank); } if value.chars().any(char::is_control) { @@ -817,7 +837,7 @@ fn validated_name(value: &str) -> Result { if value.chars().count() > MAX_NAME_CHARS { return Err(MasterBindingError::NameTooLong); } - Ok(value.to_string()) + Ok(()) } /// Folds the punctuation an operator happened to type: NFC-equivalent dash and diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index fbaedf54..c48bd4e1 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -82,6 +82,30 @@ fn unusable_names_are_refused_at_the_boundary() { assert_eq!(SourceEntity::new(0, ""), Err(MasterBindingError::NameBlank)); } +#[test] +fn an_observed_master_name_is_retained_verbatim_while_a_source_name_is_trimmed() { + // A caller writes the bound name back to Tally byte for byte. Trimming an + // observed name here would report a spelling that does not exist and + // refuse at the write gate with no explanation. + let catalog = ledgers(&[" Alpha Traders ", "Beta Supply"]); + assert_eq!(catalog.names().next(), Some(" Alpha Traders ")); + let binding = bind_one_name(&catalog, "Alpha Traders"); + assert_eq!(binding.bound_name(), Some(" Alpha Traders ")); + assert_eq!(binding.source_name, "Alpha Traders"); +} + +#[test] +fn names_differing_only_in_surrounding_whitespace_are_an_ambiguity_not_a_refused_catalog() { + let catalog = ledgers(&["Alpha Traders", "Alpha Traders "]); + let binding = bind_one_name(&catalog, "Alpha Traders"); + // Byte equality still picks the exact one; the near-identical sibling is + // not a reason to fail the whole read. + assert_eq!(binding.bound_name(), Some("Alpha Traders")); + let other = bind_one_name(&catalog, "alpha traders"); + assert_eq!(reason(&other), UnboundReason::NameAmbiguous); + assert_eq!(candidate_names(&other), ["Alpha Traders", "Alpha Traders "]); +} + #[test] fn an_identifier_hint_that_yields_nothing_is_refused_rather_than_ignored() { assert_eq!( @@ -505,3 +529,330 @@ fn a_bound_status_serializes_without_a_score_field() { assert!(json.get("score").is_none()); assert!(json.get("confidence").is_none()); } + +// --------------------------------------------------------------------------- +// Characterization against a realistically shaped book +// +// Every rule above is tested in isolation on a handful of names. Three of the +// rules only engage at scale — common-token suppression needs 20+ entries, +// candidate capping needs 25+, and the prefix ranges only matter when many +// keys share a head — so their interaction is untested by any of it. +// +// This section fabricates one 200-master catalog carrying the naming +// pathologies actually recorded (a firm word on most ledgers, numbers typed +// into party names, a near-duplicate sales trio, a masked bank last-four) and +// pins the *outcome* for a document-sized set of source names. +// +// The assertion that matters is not the count. It is that **no entity binds to +// a master a human would not have chosen**: a wrong bind puts money against the +// wrong party, and is strictly worse than an unbound row. The counts are pinned +// underneath it so that loosening a threshold has to move a number in a diff. +// +// This is fabricated input. It characterizes the rules and is not evidence +// about any Tally instance or any real book's bindability. +// --------------------------------------------------------------------------- + +const GREEK: [&str; 20] = [ + "ALPHA", "BETA", "GAMMA", "DELTA", "EPSILON", "ZETA", "ETA", "THETA", "IOTA", "KAPPA", + "LAMBDA", "MU", "NU", "XI", "OMICRON", "PI", "RHO", "SIGMA", "TAU", "UPSILON", +]; + +/// One fabricated book: 200 ledgers, shaped like a small trading firm's. +fn fabricated_book() -> Vec { + let mut names = Vec::new(); + // 20 party ledgers with a number typed into the name, as operators do. + for (index, greek) in GREEK.iter().enumerate() { + names.push(format!( + "{greek} PLACEHOLDER ({})", + 5_550_001_001_u64 + index as u64 + )); + } + // 20 party ledgers without one. + for greek in GREEK { + names.push(format!("{greek} PLACEHOLDER TRADING CO")); + } + // The near-duplicate trio that one engagement actually met. + names.push("ALPHA SALE".to_string()); + names.push("ALPHA SALES".to_string()); + names.push("SALES - ALPHA".to_string()); + // Tax heads, which share heavy word overlap with each other. + for head in ["CGST", "SGST", "IGST"] { + for side in ["OUTPUT", "INPUT"] { + for rate in ["9%", "18%"] { + names.push(format!("{head} {side} {rate}")); + } + } + } + // Banks, carrying a masked last-four rather than a full account number. + names.push("PLACEHOLDER BANK CA 2129".to_string()); + names.push("PLACEHOLDER BANK OD 7745".to_string()); + // The accounts every book has. + for name in [ + "Cash", + "Suspense Placeholder", + "Round Off", + "Profit & Loss A/c", + ] { + names.push(name.to_string()); + } + // Filler carrying one firm-wide word, to the size of a real small book. + let mut index = 0; + while names.len() < 200 { + names.push(format!("PLACEHOLDER UNIT {index:03}")); + index += 1; + } + names +} + +#[derive(Debug, PartialEq, Eq)] +enum Expected { + /// The master a human reading the source would have chosen. + Bound(&'static str), + Unbound(UnboundReason), +} + +/// What one document names, and what a human would do with each. +fn fabricated_document() -> Vec<(&'static str, Option<&'static str>, Expected)> { + vec![ + // Named exactly as the book spells it. + ("Cash", None, Expected::Bound("Cash")), + ("CGST OUTPUT 9%", None, Expected::Bound("CGST OUTPUT 9%")), + // Case and spacing noise from the source system. + ( + " cgst output 9% ", + None, + Expected::Bound("CGST OUTPUT 9%"), + ), + ( + "beta placeholder trading co", + None, + Expected::Bound("BETA PLACEHOLDER TRADING CO"), + ), + // The engagement case: the source names the party its own way and + // carries the number in a separate column. Name matching would offer + // twenty wrong parties; the number decides. + ( + "GAMMA. K.", + Some("5550001003"), + Expected::Bound("GAMMA PLACEHOLDER (5550001003)"), + ), + // Same, with the number inside the name rather than a hint. + ( + "DELTA K 5550001004", + None, + Expected::Bound("DELTA PLACEHOLDER (5550001004)"), + ), + // A truncated party name: one candidate, and one candidate is still + // not a decision. + ( + "EPSILON PLACEHOLDER TRADING", + None, + Expected::Unbound(UnboundReason::NearMiss), + ), + // The near-duplicate trio. Nothing here may resolve. + ("ALPHA SALE", None, Expected::Bound("ALPHA SALE")), + ("ALPHA", None, Expected::Unbound(UnboundReason::NearMiss)), + // A masked bank last-four must not bind on four digits. + ( + "PLACEHOLDER BANK 2129", + None, + Expected::Unbound(UnboundReason::NearMiss), + ), + // A party the book simply does not have. + ( + "OMEGA WHOLESALE", + None, + Expected::Unbound(UnboundReason::NoCandidate), + ), + // A number the book does not carry: the hint finds nothing, and the + // name is left to answer on its own. + ( + "PSI SUPPLY", + Some("5559999999"), + Expected::Unbound(UnboundReason::NoCandidate), + ), + ] +} + +#[test] +fn a_document_against_a_realistic_book_binds_only_where_a_human_would() { + let names = fabricated_book(); + assert_eq!(names.len(), 200); + let catalog = + MasterCatalog::new(MasterClass::Ledger, &names).expect("the fabricated book is valid"); + assert_eq!(catalog.master_count(), 200); + + let document = fabricated_document(); + let entities = document + .iter() + .enumerate() + .map(|(position, (name, hint, _))| match hint { + Some(hint) => SourceEntity::with_identifier_hints(position, name, [*hint]), + None => SourceEntity::new(position, name), + }) + .map(|entity| entity.expect("fabricated source names are valid")) + .collect::>(); + let report = bound(&catalog, &entities); + + for ((source, _, expected), binding) in document.iter().zip(report.entities()) { + match (&binding.status, expected) { + (BindingStatus::Bound { catalog_name, .. }, Expected::Bound(intended)) => assert_eq!( + catalog_name, intended, + "{source:?} bound to a master a human would not have chosen" + ), + ( + BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved), + Expected::Unbound(intended), + ) => assert_eq!( + unresolved.reason, *intended, + "{source:?} was unbound for an unintended reason" + ), + (status, expected) => { + panic!("{source:?}: expected {expected:?}, got {status:?}") + } + } + } + + // The shape of the answer, pinned so a loosened threshold moves a number. + let totals = report.totals(); + assert_eq!(totals.requested, 12); + assert_eq!(totals.bound, 7); + assert_eq!(totals.ambiguous, 3); + assert_eq!(totals.unmatched, 2); + assert_eq!(totals.requested, totals.bound + totals.unbound); +} + +#[test] +fn a_firm_wide_word_does_not_drag_the_whole_book_into_every_candidate_list() { + // "placeholder" is carried by most of this book. Without suppression the + // unbound list stops being a work item and becomes a second data-entry job. + let names = fabricated_book(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let carriers = names + .iter() + .filter(|name| name.to_lowercase().contains("placeholder")) + .count(); + assert!(carriers > 100, "the fabricated book must exercise this"); + + let binding = bind_one_name(&catalog, "OMEGA PLACEHOLDER"); + let unresolved = binding.unresolved().expect("unbound"); + assert!( + unresolved.candidate_count <= MAX_CANDIDATES_PER_ENTITY, + "a firm-wide word pulled in {} candidates", + unresolved.candidate_count + ); +} + +/// The mutations a source document actually applies to a name it copied from +/// somewhere else: case, spacing, a dropped tail, a dropped last word. +fn source_mutations(name: &str) -> Vec { + let mut mutations = vec![name.to_uppercase(), name.to_lowercase()]; + mutations.push(format!( + " {} ", + name.split_whitespace().collect::>().join(" ") + )); + let characters = name.chars().collect::>(); + let kept = characters.len() * 4 / 5; + if kept >= MIN_PREFIX_KEY_CHARS { + mutations.push(characters[..kept].iter().collect()); + } + let words = name.split_whitespace().collect::>(); + if words.len() > 2 { + mutations.push(words[..words.len() - 1].join(" ")); + } + mutations +} + +#[test] +fn no_mutation_of_a_master_name_ever_binds_to_a_different_master() { + // The safety property, stated over a whole book rather than three chosen + // names: a wrong bind puts money against the wrong party, and is strictly + // worse than an unbound row. Roughly a thousand cases. + // + // Mutations that collide with *another* master under the comparison key + // are excluded, and deliberately so: truncating `ALPHA SALES` by one + // character yields `ALPHA SALE`, which is a real and different ledger. No + // rule can distinguish a truncation of one name from an exact spelling of + // another, and binding it to the name it actually spells is correct. + // + // **This sweep was checked against two positive controls**, because an + // assertion that has never failed is not yet known to be an instrument: + // + // - Resolving a near-miss to its first-ordered candidate — precisely what + // the deleted MCP helper did through `exact_live_spelling` — trips it on + // a truncated party name. So it does report presence. + // - Binding a *lone* candidate does **not** trip it, because in this book a + // lone candidate is nearly always the master the mutation came from. That + // regression is caught by `a_single_candidate_still_does_not_bind` and by + // the two prefix tests instead. + // + // Read this test as "no mutation reaches the wrong master", never as "no + // rule change can loosen binding". + let names = fabricated_book(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let keys = names + .iter() + .map(|name| (comparison_key(name), name.as_str())) + .collect::>(); + + let mut checked = 0_usize; + let mut self_bound = 0_usize; + for name in &names { + for mutation in source_mutations(name) { + let key = comparison_key(&mutation); + if keys.get(&key).is_some_and(|owner| owner != name) { + continue; // the mutation spells a different real ledger + } + checked += 1; + let binding = bind_one_name(&catalog, &mutation); + match binding.bound_name() { + None => {} + Some(bound_to) => { + assert_eq!( + bound_to, name, + "mutation {mutation:?} of {name:?} bound to a different master" + ); + self_bound += 1; + } + } + } + } + assert!( + checked > 900, + "the sweep must actually cover the book: {checked}" + ); + // Most mutations are case and spacing noise, which must still bind. + assert!( + self_bound * 2 > checked, + "only {self_bound} of {checked} mutations bound at all" + ); +} + +#[test] +fn a_number_typed_into_a_master_name_finds_it_from_any_source_name() { + // The rule that decided the case fuzzy matching got wrong, exercised + // against every party ledger in the book rather than one. + let names = fabricated_book(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let numbered = names + .iter() + .filter(|name| name.contains("PLACEHOLDER (")) + .collect::>(); + assert_eq!(numbered.len(), 20); + + for name in numbered { + let number = name + .rsplit_once('(') + .and_then(|(_, tail)| tail.strip_suffix(')')) + .expect("fabricated party names carry a number"); + // A source name sharing nothing with the ledger name at all. + let entity = SourceEntity::with_identifier_hints(0, "UNRELATED SOURCE PARTY", [number]) + .expect("valid"); + let report = bound(&catalog, &[entity]); + assert_eq!( + report.entities()[0].bound_name(), + Some(name.as_str()), + "the number typed into {name:?} did not find it" + ); + } +} From e8ad41d1d4f4a4bff5695892b2fa900113951454 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Thu, 10 Sep 2026 22:34:24 +0530 Subject: [PATCH 03/75] Narrow the existing-ledger list to what binding could defend The preparation screen listed every ledger in the company for every source entry, so the operator read the whole catalogue once per line. #288 already returns a per-entry binding; nothing drew it. Each entry's list now opens with the ledger binding matched, or with the candidates it could not choose between, followed by the complete catalogue under its own heading. The full list stays reachable in every case: this is a shortcut through it, never a filter on it. Nothing is selected for the operator. A near miss says plainly that nothing was chosen and reports a truncated candidate list truthfully, and a capture carrying no bindings renders exactly as before. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- scripts/source-draft-screen.test.tsx | 112 ++++++++++++++++++ src/SourceDraftScreen.tsx | 46 ++++++- 4 files changed, 160 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index f9348144..a42b3308 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "3181d64fe41764a34c11a588a191c5a9df1f17152b4b90091cfaadf88829472f", + "compatibility_surface_sha256": "602f16036af42f97e8a7dba8f66ece7a6d54e058bbc8d1dd4d057e2b71560286", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 92e70e12..6585c69f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -715,7 +715,7 @@ }, { "path": "src/SourceDraftScreen.tsx", - "sha256": "f4742923e62f725444822c50f82068e9dfe76291697100ca692b1ceb33beb1c4" + "sha256": "d81fcda5ae03cf8fa23961397118f0d977d5db274e972006d84c2b0e70fdecc0" }, { "path": "src/TallyReadinessFlow.tsx", @@ -842,5 +842,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "3181d64fe41764a34c11a588a191c5a9df1f17152b4b90091cfaadf88829472f" + "manifest_sha256": "602f16036af42f97e8a7dba8f66ece7a6d54e058bbc8d1dd4d057e2b71560286" } \ No newline at end of file diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index 41dafc06..4edcd3bd 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -308,6 +308,118 @@ test("requires an explicit current-session re-read before treating a saved match root.unmount(); }); +test("lists the bound ledger first without selecting it, and keeps the whole catalogue reachable", async () => { + const boundCatalog = { + ...catalog, + targets: ["Alpha placeholder", "Beta placeholder", "Gamma placeholder"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: "Beta placeholder", + bound_basis: "identifier" as const, + unbound_reason: null, + candidates: [], + candidate_count: 0, + candidates_truncated: false, + }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(boundCatalog); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + + const target = host.querySelector("#source-draft-1-entry-0-ledger")!; + // Narrowing must never choose. A pre-selected value is an auto-resolution. + expect(target.value).toBe(""); + const groups = Array.from(target.querySelectorAll("optgroup")).map((group) => group.label); + expect(groups).toEqual(["Matched to this source line", "All 3 existing ledgers"]); + const matched = Array.from(target.querySelectorAll("optgroup")[0].querySelectorAll("option")).map((option) => option.value); + expect(matched).toEqual(["Beta placeholder"]); + // The full catalogue stays reachable; narrowing is a shortcut, not a filter. + const all = Array.from(target.querySelectorAll("optgroup")[1].querySelectorAll("option")).map((option) => option.value); + expect(all).toEqual(["Alpha placeholder", "Beta placeholder", "Gamma placeholder"]); + expect(host.textContent).toContain("Listed first because a number inside the ledger name matches this source line."); + expect(host.textContent).not.toContain("recommended"); + root.unmount(); +}); + +test("lists candidates first for a near miss and states that nothing was chosen", async () => { + const nearMissCatalog = { + ...catalog, + targets: ["Alpha placeholder", "Beta placeholder", "Gamma placeholder"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: null, + bound_basis: null, + unbound_reason: "master_binding_near_miss", + candidates: ["Alpha placeholder", "Gamma placeholder"], + candidate_count: 2, + candidates_truncated: false, + }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(nearMissCatalog); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + + const target = host.querySelector("#source-draft-1-entry-0-ledger")!; + expect(target.value).toBe(""); + const groups = Array.from(target.querySelectorAll("optgroup")).map((group) => group.label); + expect(groups).toEqual(["Possible for this source line", "All 3 existing ledgers"]); + const possible = Array.from(target.querySelectorAll("optgroup")[0].querySelectorAll("option")).map((option) => option.value); + expect(possible).toEqual(["Alpha placeholder", "Gamma placeholder"]); + expect(host.textContent).toContain("No single ledger matched this source line, so nothing is chosen. 2 possible ledgers are listed first; the full list of 3 follows."); + root.unmount(); +}); + +test("reports a truncated candidate list truthfully and falls back to the flat catalogue without bindings", async () => { + const truncatedCatalog = { + ...catalog, + targets: ["Alpha placeholder", "Beta placeholder"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: null, + bound_basis: null, + unbound_reason: "master_binding_near_miss", + candidates: ["Alpha placeholder"], + candidate_count: 40, + candidates_truncated: true, + }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(truncatedCatalog); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + expect(host.textContent).toContain("1 of 40 possible ledger is listed first"); + root.unmount(); +}); + +test("a capture without bindings still renders the whole catalogue and claims nothing", async () => { + // An older capture, or one the backend could not narrow, must not lose the + // list the operator came for. + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(catalog); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + + const target = host.querySelector("#source-draft-1-entry-0-ledger")!; + const groups = Array.from(target.querySelectorAll("optgroup")).map((group) => group.label); + expect(groups).toEqual(["Existing ledgers"]); + expect(Array.from(target.querySelectorAll("option")).map((option) => option.value)).toEqual(["", "Existing target"]); + expect(host.textContent).not.toContain("listed first"); + root.unmount(); +}); + test("renders unusual ledger spaces visibly while binding the exact selected catalog target", async () => { const whitespaceCatalog = { ...catalog, targets: ["Cash", " Cash "] }; mocks.invoke diff --git a/src/SourceDraftScreen.tsx b/src/SourceDraftScreen.tsx index 563152f1..702db428 100644 --- a/src/SourceDraftScreen.tsx +++ b/src/SourceDraftScreen.tsx @@ -5,6 +5,7 @@ import "./source-draft.css"; import { SourceDraft, SourceDraftAction, + SourceDraftCatalogBinding, SourceDraftCatalogTargets, SourceDraftProposedEntry, SourceDraftProposal, @@ -63,6 +64,39 @@ function displayCatalogTarget(target: string) { return target.replace(/(^ +| +$| {2,})/g, (spaces) => "␠".repeat(spaces.length)); } +function catalogBindingFor(catalog: SourceDraftCatalogTargets | null, rowPosition: number, entryPosition: number) { + return catalog?.bindings?.find((binding) => binding.row_position === rowPosition && binding.entry_position === entryPosition) ?? null; +} + +/// The ledgers Bridge could defend for this source name, most defensible first. +/// A bound target leads only because binding decided it; a candidate list is +/// ordered by the rule that surfaced it and carries no ranking of its own. +function narrowedTargets(binding: SourceDraftCatalogBinding | null) { + if (!binding) return []; + return binding.bound_target ? [binding.bound_target] : binding.candidates; +} + +/// States what binding did, in the operator's terms. It never says "best", +/// "recommended" or "suggested match": nothing here is chosen for anyone, and a +/// listed ledger is a shortcut through the list, not an answer. +function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: number) { + if (!binding) return null; + if (binding.bound_target) { + const how = binding.bound_basis === "identifier" + ? "a number inside the ledger name" + : binding.bound_basis === "exact_name" + ? "the exact ledger name" + : "the same ledger name, differently written"; + return `Listed first because ${how} matches this source line. Nothing is selected for you, and choosing it stays an unapproved proposal.`; + } + if (binding.candidate_count === 0) { + return `No existing ledger matched this source line. All ${total} are listed.`; + } + const shown = binding.candidates.length; + const listed = binding.candidates_truncated ? `${shown} of ${binding.candidate_count}` : `${shown}`; + return `No single ledger matched this source line, so nothing is chosen. ${listed} possible ${shown === 1 ? "ledger is" : "ledgers are"} listed first; the full list of ${total} follows.`; +} + function hasStartedProposal(row: SourceDraftRow) { const proposal = row.proposal; return Boolean(proposal.date || proposal.voucher_type || proposal.narration !== null || proposal.notes.trim() || proposal.entries.some((entry) => entry.ledger !== null || entry.side !== null || entry.amount !== null)); @@ -467,6 +501,9 @@ function SourceDraftEditor({ row, disabled, catalog, catalogSelections, onSelect
{proposal.entries.map((entry, index) => { const entryId = (name: string) => fieldId(`entry-${index}-${name}`); + const binding = catalogBindingFor(catalog, row.position, index + 1); + const narrowed = narrowedTargets(binding); + const bindingSummary = catalog ? catalogBindingSummary(binding, catalog.targets.length) : null; return

Source line {index + 1}{sourceEntryLabel(row.entries[index] ?? { position: index, source_ledger: "", source_amount: "", source_polarity: "" })}

@@ -474,10 +511,17 @@ function SourceDraftEditor({ row, disabled, catalog, catalogSelections, onSelect {catalog ? <> {entry.ledger && }

{catalogSelections[catalogSelectionKey(row.position, index + 1)] === entry.ledger ? "This current-session target was re-read and bound. It remains an unapproved proposal." : entry.ledger ? `Saved unverified target: ${entry.ledger}. Select it to check it against this current capture.` : "Choose a current existing ledger to make an unapproved proposal."}

+ {bindingSummary &&

{bindingSummary}

} : <> onUpdateEntry(index, (current) => ({ ...current, ledger: emptyToNull(event.target.value) }))} disabled={disabled} />

{entry.ledger ? `Saved unverified target: ${entry.ledger}` : "Load existing ledgers to choose a target."}

From 81bc763622aa59e2f34721dc2b93b5854178ec5d Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Thu, 10 Sep 2026 23:37:14 +0530 Subject: [PATCH 04/75] Fix the review findings, and the candidate quality live data exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten review findings, read against the tree rather than taken at face value, and all reproduced. The severe one: bind_one selected Identifier before ExactName, so a ledger carrying a number, requested byte-exactly, returned match_state identifier while the write gate admits exact only. Every ledger with a phone or account number in its name was permanently unimportable — the exact population this contract was built for. When the two signals agree it now reports the byte-level fact. Also: neither side trims a name any more, so a trailing space cannot claim byte equality it does not have; digits inside a mixed code are no longer emitted as a standalone numeric; all admitted eight-digit date orders are excluded, not just year-first; more identifiers than the bound is refused rather than truncated, which could hide a conflict; the source-entity bound now covers what the source parser admits and the desktop states whether narrowing ran; desktop candidate bytes are capped in aggregate; a binding refusal keeps the catalogue evidence it already read; and master_binding.rs is sealed into the compatibility surface, which needed a deliberate one-file cap raise since it now decides admission outcomes. The larger finding came from running the binder over 470 real ledger names from all 16 synthetic books. Prefix matching offered a median of 40 candidates, 63% of the catalogue, and omitted the right master a third of the time: a truncated name reaches a whole family, and an alphabetically capped slice of DN Party 001..120 does not contain DN Party 057. A prefix matching more masters than the cap is now counted and deliberately not listed. Re-measured on the same names: where candidates are listed the right master is present in 403 of 403 rows, median list length 2. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 14 +- .../bridge-tally-core/src/master_binding.rs | 207 ++++++++++++------ .../src/master_binding_tests.rs | 115 +++++++++- src-tauri/src/agent_import.rs | 10 +- src-tauri/src/agent_import_tests.rs | 16 +- src-tauri/src/source_draft/catalog.rs | 59 +++-- src/source-draft-types.ts | 3 + tools/bridge-tally-compatibility/src/lib.rs | 14 +- 9 files changed, 336 insertions(+), 104 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index f9348144..1125d95d 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "3181d64fe41764a34c11a588a191c5a9df1f17152b4b90091cfaadf88829472f", + "compatibility_surface_sha256": "09bc6b215b23e5a4feed7210e0289773a1f415846c714e918fa0d00d67c4c944", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 92e70e12..d42e3eb7 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -141,6 +141,10 @@ "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", "sha256": "58674602eb3131c101ace7638d43cf550d74b29eb3848b0c908687bb2c9fbf9c" }, + { + "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", + "sha256": "253f74e93977d04c1744aab03608eb06de7acff70b85fe549a0b95a68f4b443c" + }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", "sha256": "7579b5bfcf7aca89dd688043d3b3d8995a42403fac3ee8bd2c66ec0380b79aa4" @@ -327,7 +331,7 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "007c1c97eb65929734345fe72b8b52a9cee8ca6adcb375654080a71f6576ca3a" + "sha256": "f531cd0c3bc2a5da6b6dc53271c473bf0b1e1ef776966692a4579651989a67da" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -575,7 +579,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "f283ace5705e679b9a25ea605fd53fc7775878b93065b0c552f4c4c9234d312d" + "sha256": "53cfe9d0d248af44361b7d9cb314e301cc0dba87547c54bd59ea90ae35117325" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -771,7 +775,7 @@ }, { "path": "src/source-draft-types.ts", - "sha256": "429d2b69a2948f8fdcb7da602c5b895d2dff42c3db7f48ba44fc9d10db701321" + "sha256": "de6b108f6654d6ae4bcf1855e9e3a758412472d694e1b2124eff6553315dd0e5" }, { "path": "src/source-draft.css", @@ -807,7 +811,7 @@ }, { "path": "tools/bridge-tally-compatibility/src/lib.rs", - "sha256": "4c07fdfe42bf7fd5010068d717799b3b9c52a3dbd5bc2c85850855f184d6d229" + "sha256": "8b07e99c7335d6207664302aea4fd800ed524201c8c50d1c7edcf02982d1bce1" }, { "path": "tools/bridge-tally-compatibility/src/main.rs", @@ -842,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "3181d64fe41764a34c11a588a191c5a9df1f17152b4b90091cfaadf88829472f" + "manifest_sha256": "09bc6b215b23e5a4feed7210e0289773a1f415846c714e918fa0d00d67c4c944" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 5a70a923..cb119426 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -18,8 +18,11 @@ use unicode_normalization::UnicodeNormalization; /// Most masters one catalog may carry. pub const MAX_CATALOG_ENTRIES: usize = 20_000; -/// Most entities one binding request may name. -pub const MAX_SOURCE_ENTITIES: usize = 5_000; +/// Most entities one binding request may name. This must stay at or above what +/// a consumer's own parser admits: Bridge's source-draft parser accepts 2,000 +/// vouchers of 20 entries, and a bound below that turned a valid draft into a +/// silently empty binding result. +pub const MAX_SOURCE_ENTITIES: usize = 40_000; /// Longest accepted master or source name, in characters. This bounds /// pathological input; it is not a claim about what Tally accepts, and a /// caller with a stricter contract of its own enforces that at its own @@ -27,8 +30,9 @@ pub const MAX_SOURCE_ENTITIES: usize = 5_000; pub const MAX_NAME_CHARS: usize = 16_384; /// Most candidates retained per unbound entity. pub const MAX_CANDIDATES_PER_ENTITY: usize = 25; -/// Most identifiers extracted from one name. -pub const MAX_IDENTIFIERS_PER_NAME: usize = 8; +/// Most identifiers one name may carry. Exceeding it is refused, never +/// truncated. +pub const MAX_IDENTIFIERS_PER_NAME: usize = 32; /// Digits a numeric run needs before it is treated as an identifier. Eight /// excludes a year, a rate, a house number and a masked last-four; a mobile, /// an account number and a customer code all clear it. @@ -42,6 +46,10 @@ pub const MIN_CODE_IDENTIFIER_DIGITS: usize = 2; pub const MIN_PREFIX_KEY_CHARS: usize = 3; /// Shortest token that may take part in a shared-token near-miss. pub const MIN_TOKEN_CHARS: usize = 3; +/// Masters a single prefix may match before the prefix stops discriminating. +/// Beyond this the match is a name *family*, and an arbitrary slice of it is +/// worse than saying so. +pub const MAX_PREFIX_FAMILY: usize = MAX_CANDIDATES_PER_ENTITY; /// Share of the catalog above which a token stops discriminating. pub const COMMON_TOKEN_PERCENT: usize = 10; /// Catalog size below which no token is treated as common. @@ -86,6 +94,10 @@ pub enum MasterBindingError { /// silently, so it fails loudly instead. #[error("identifier hint carried no usable identifier")] IdentifierHintUnusable, + /// Keeping only the first few would discard the identifier that pointed at + /// a different master, turning a conflict into a bind. + #[error("name carried more identifiers than the bound")] + TooManyIdentifiers, #[error("fallback master was not a current catalog entry")] FallbackNotInCatalog, } @@ -102,6 +114,7 @@ impl MasterBindingError { Self::NameTooLong => "master_name_too_long", Self::NameUnsafe => "master_name_unsafe", Self::IdentifierHintUnusable => "master_identifier_hint_unusable", + Self::TooManyIdentifiers => "master_identifiers_too_many", Self::FallbackNotInCatalog => "master_fallback_not_in_catalog", } } @@ -176,6 +189,12 @@ pub enum UnboundReason { /// Candidates exist but none was decisive. This is the four-near-miss case /// that rejected a batch once; a single candidate stays here too. NearMiss, + /// The source name matches a whole family of masters and distinguishes + /// none of them — a truncated `DN Party 0` against `DN Party 001`…`120`. + /// Measured live: listing an arbitrary capped slice of such a family put + /// the right master out of view about a third of the time, so the family + /// is counted and deliberately not listed. + NoDiscriminatingCandidate, /// No rule produced a candidate. The master is probably missing. NoCandidate, } @@ -188,6 +207,7 @@ impl UnboundReason { Self::IdentifierNameConflict => "master_binding_identifier_name_conflict", Self::NameAmbiguous => "master_binding_name_ambiguous", Self::NearMiss => "master_binding_near_miss", + Self::NoDiscriminatingCandidate => "master_binding_no_discriminating_candidate", Self::NoCandidate => "master_binding_no_candidate", } } @@ -429,10 +449,10 @@ impl SourceEntity { name: &str, hints: impl IntoIterator, ) -> Result { - let name = validated_source_name(name)?; - let mut identifiers = extract_identifiers(&name); + let name = validated_name(name)?; + let mut identifiers = extract_identifiers(&name)?; for hint in hints { - let extracted = extract_identifiers(hint); + let extracted = extract_identifiers(hint)?; if extracted.is_empty() { return Err(MasterBindingError::IdentifierHintUnusable); } @@ -440,7 +460,9 @@ impl SourceEntity { } identifiers.sort(); identifiers.dedup(); - identifiers.truncate(MAX_IDENTIFIERS_PER_NAME); + if identifiers.len() > MAX_IDENTIFIERS_PER_NAME { + return Err(MasterBindingError::TooManyIdentifiers); + } Ok(Self { position, key: comparison_key(&name), @@ -501,14 +523,14 @@ impl MasterCatalog { if entries.len() >= MAX_CATALOG_ENTRIES { return Err(MasterBindingError::CatalogTooLarge); } - let name = validated_catalog_name(name.as_ref())?; + let name = validated_name(name.as_ref())?; if by_name.contains_key(&name) { return Err(MasterBindingError::CatalogDuplicateName); } by_name.insert(name.clone(), entries.len()); let key = comparison_key(&name); entries.push(CatalogEntry { - identifiers: extract_identifiers(&name), + identifiers: extract_identifiers(&name)?, tokens: tokens_of(&key), key, name, @@ -636,19 +658,26 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { // An identifier pointing at one master while the name exactly names // another is a disagreement between two strong signals; it is shown, // not silently decided in the identifier's favour. - if exact.is_some_and(|index| index != matched) { - unresolved_status( + match exact { + // Two strong signals disagreeing is shown, not settled. + Some(index) if index != matched => unresolved_status( catalog, entity, UnboundReason::IdentifierNameConflict, exact, &identifier_matches, - ) - } else { - BindingStatus::Bound { + ), + // They agree. Report the stronger, byte-level fact: the write gate + // admits `ExactName` only, and reporting `Identifier` here made + // every ledger carrying a number permanently unimportable. + Some(_) => BindingStatus::Bound { + catalog_name: catalog.entries[matched].name.clone(), + basis: BindingBasis::ExactName, + }, + None => BindingStatus::Bound { catalog_name: catalog.entries[matched].name.clone(), basis: BindingBasis::Identifier, - } + }, } } else if let Some(index) = exact { BindingStatus::Bound { @@ -669,13 +698,16 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { &identifier_matches, ), None => { - let candidates = collect_candidates(catalog, entity, &identifier_matches); - let reason = if candidates.is_empty() { - UnboundReason::NoCandidate - } else { + let (candidates, prefix_family) = + collect_candidates(catalog, entity, &identifier_matches); + let reason = if !candidates.is_empty() { UnboundReason::NearMiss + } else if prefix_family > MAX_PREFIX_FAMILY { + UnboundReason::NoDiscriminatingCandidate + } else { + UnboundReason::NoCandidate }; - unresolved_from(entity, reason, candidates) + unresolved_from(entity, reason, candidates, prefix_family) } } }; @@ -694,20 +726,21 @@ fn unresolved_status( exact: Option, identifier_matches: &BTreeSet, ) -> BindingStatus { - let mut candidates = collect_candidates(catalog, entity, identifier_matches); + let (mut candidates, prefix_family) = collect_candidates(catalog, entity, identifier_matches); if let Some(index) = exact { let name = catalog.entries[index].name.as_str(); if !candidates.iter().any(|(candidate, _)| candidate == name) { candidates.push((name.to_string(), CandidateRule::NormalizedEqual)); } } - unresolved_from(entity, reason, candidates) + unresolved_from(entity, reason, candidates, prefix_family) } fn unresolved_from( entity: &SourceEntity, reason: UnboundReason, candidates: Vec<(String, CandidateRule)>, + prefix_family: usize, ) -> BindingStatus { let mut ordered = candidates; ordered.sort_by(|left, right| { @@ -716,8 +749,10 @@ fn unresolved_from( .cmp(&right.1.rank()) .then_with(|| left.0.cmp(&right.0)) }); - let candidate_count = ordered.len(); - let candidates_truncated = candidate_count > MAX_CANDIDATES_PER_ENTITY; + // A suppressed family is still counted. The operator is told how many + // masters the name reaches even when none of them is worth listing. + let candidate_count = ordered.len().max(prefix_family); + let candidates_truncated = candidate_count > ordered.len().min(MAX_CANDIDATES_PER_ENTITY); let candidates = ordered .into_iter() .take(MAX_CANDIDATES_PER_ENTITY) @@ -744,7 +779,7 @@ fn collect_candidates( catalog: &MasterCatalog, entity: &SourceEntity, identifier_matches: &BTreeSet, -) -> Vec<(String, CandidateRule)> { +) -> (Vec<(String, CandidateRule)>, usize) { let mut best: BTreeMap = BTreeMap::new(); let mut offer = |index: usize, rule: CandidateRule| { best.entry(index) @@ -764,18 +799,24 @@ fn collect_candidates( offer(*index, CandidateRule::NormalizedEqual); } } + let mut prefix_family = 0_usize; if entity.key.chars().count() >= MIN_PREFIX_KEY_CHARS { // The key index is ordered, so both prefix directions are range or // point lookups rather than a scan of the whole catalog per entity. - for (key, holders) in catalog.by_key.range(entity.key.clone()..) { - if !key.starts_with(&entity.key) { - break; - } - if key == &entity.key { - continue; - } - for index in holders { - offer(*index, CandidateRule::CatalogPrefix); + let extending = catalog + .by_key + .range(entity.key.clone()..) + .take_while(|(key, _)| key.starts_with(&entity.key)) + .filter(|(key, _)| *key != &entity.key) + .flat_map(|(_, holders)| holders.iter().copied()) + .collect::>(); + prefix_family = extending.len(); + // A prefix matching a whole family distinguishes nothing inside it, and + // an arbitrary capped slice is worse than none: measured against live + // books, that slice omitted the right master about a third of the time. + if prefix_family <= MAX_PREFIX_FAMILY { + for index in extending { + offer(index, CandidateRule::CatalogPrefix); } } for split in MIN_PREFIX_KEY_CHARS..entity.key.len() { @@ -804,29 +845,29 @@ fn collect_candidates( } } - best.into_iter() - .map(|(index, rule)| (catalog.entries[index].name.clone(), rule)) - .collect() + ( + best.into_iter() + .map(|(index, rule)| (catalog.entries[index].name.clone(), rule)) + .collect(), + prefix_family, + ) } -/// An observed master name is retained **verbatim**. Surrounding whitespace is -/// part of what the book returned, and a caller that acts on a binding writes -/// this string back to Tally byte for byte; trimming it here would report a -/// spelling that does not exist and refuse at the write gate with no -/// explanation. The comparison key collapses whitespace anyway, so a source -/// name still matches across the difference. -fn validated_catalog_name(value: &str) -> Result { +/// A name is retained **verbatim**, on both sides. +/// +/// An observed master name is written back to Tally byte for byte by a caller +/// that acts on a binding, so trimming it would report a spelling the book does +/// not contain. A requested source name is what byte equality is judged +/// against, so trimming it would let `Bank ` claim an exact match on `Bank` +/// while the import file still carries the trailing space. The comparison key +/// collapses surrounding whitespace anyway, so the two still meet as a +/// normalized match — which is a bind the write gate does not admit, and that +/// is the correct, loud outcome. +fn validated_name(value: &str) -> Result { validate_name_bounds(value)?; Ok(value.to_string()) } -/// A source name is trimmed: leading and trailing whitespace is document noise -/// rather than an observation, and nothing is ever written back from it. -fn validated_source_name(value: &str) -> Result { - validate_name_bounds(value)?; - Ok(value.trim().to_string()) -} - fn validate_name_bounds(value: &str) -> Result<(), MasterBindingError> { if value.trim().is_empty() { return Err(MasterBindingError::NameBlank); @@ -871,20 +912,15 @@ fn tokens_of(key: &str) -> BTreeSet { /// A numeric run may hold `-` and `/` internally, so a punctuated account /// number and a plain one agree; it may not hold spaces, so separated digit /// groups fail closed to a near-miss rather than fusing into a false -/// identifier. -fn extract_identifiers(value: &str) -> Vec { +/// identifier. Digits that sit inside a mixed letter-and-digit token belong to +/// that token's code and are never also emitted on their own — otherwise +/// `Part AB12345678` would collide with an unrelated `Bank 12345678`. +/// +/// Refuses rather than truncates when a name carries more identifiers than the +/// bound: silently keeping the first few can turn a conflict into a bind by +/// discarding the identifier that pointed elsewhere. +fn extract_identifiers(value: &str) -> Result, MasterBindingError> { let mut identifiers = BTreeSet::new(); - for run in value.split(|character: char| { - !(character.is_ascii_digit() || character == '-' || character == '/') - }) { - let digits = run.chars().filter(char::is_ascii_digit).collect::(); - if digits.len() >= MIN_NUMERIC_IDENTIFIER_DIGITS && !is_plausible_date(&digits) { - identifiers.insert(Identifier { - kind: IdentifierKind::Numeric, - value: digits, - }); - } - } for token in value.split(char::is_whitespace) { let canonical = token .chars() @@ -901,23 +937,48 @@ fn extract_identifiers(value: &str) -> Vec { kind: IdentifierKind::Code, value: canonical, }); + // Its digits are part of this code, not an identifier of their own. + continue; } + for run in token.split(|character: char| { + !(character.is_ascii_digit() || character == '-' || character == '/') + }) { + let digits = run.chars().filter(char::is_ascii_digit).collect::(); + if digits.len() >= MIN_NUMERIC_IDENTIFIER_DIGITS && !is_plausible_date(&digits) { + identifiers.insert(Identifier { + kind: IdentifierKind::Numeric, + value: digits, + }); + } + } + } + if identifiers.len() > MAX_IDENTIFIERS_PER_NAME { + return Err(MasterBindingError::TooManyIdentifiers); } - let mut identifiers = identifiers.into_iter().collect::>(); - identifiers.truncate(MAX_IDENTIFIERS_PER_NAME); - identifiers + Ok(identifiers.into_iter().collect()) } -/// An eight-digit run that reads as a calendar date is a date. Excluding it -/// costs a near-miss on an account number that happens to look like one, and -/// prevents a period label binding two unrelated masters together. +/// An eight-digit run that reads as a calendar date in any order this project +/// admits is a date, not an identifier. Recognizing only `YYYYMMDD` left +/// `01012026` binding a source to an unrelated master that shares its period +/// label. Being generous here can only make a bind *less* likely, which is the +/// safe direction for a rule whose failure mode is money against the wrong +/// party. fn is_plausible_date(digits: &str) -> bool { if digits.len() != 8 { return false; } let number = |range: std::ops::Range| digits[range].parse::().unwrap_or(0); - let (year, month, day) = (number(0..4), number(4..6), number(6..8)); - (1900..=2199).contains(&year) && (1..=12).contains(&month) && (1..=31).contains(&day) + let (first, second, third, fourth) = (number(0..4), number(4..6), number(6..8), number(4..8)); + let (day, month) = (number(0..2), number(2..4)); + let year_first = + (1900..=2199).contains(&first) && (1..=12).contains(&second) && (1..=31).contains(&third); + // DDMMYYYY and MMDDYYYY are indistinguishable from each other without a + // locale, so either reading is enough to disqualify the run. + let year_last = (1900..=2199).contains(&fourth) + && ((1..=31).contains(&day) && (1..=12).contains(&month) + || (1..=12).contains(&day) && (1..=31).contains(&month)); + year_first || year_last } #[cfg(test)] diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index c48bd4e1..96e6fb10 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -352,6 +352,94 @@ fn separated_digit_groups_do_not_fuse_into_an_identifier() { assert!(entity.identifiers().is_empty()); } +// --- the review findings, pinned ------------------------------------------- + +#[test] +fn a_byte_exact_name_carrying_a_number_is_reported_exact_not_identifier() { + // The write gate admits `ExactName` only. Reporting `Identifier` when the + // two agree made every ledger with a number in its name permanently + // unimportable — the exact population this contract exists to serve. + let catalog = ledgers(&["GAMMA (5550000001)", "GAMMA ALPHA"]); + let binding = bind_one_name(&catalog, "GAMMA (5550000001)"); + assert_eq!( + binding.status, + BindingStatus::Bound { + catalog_name: "GAMMA (5550000001)".to_string(), + basis: BindingBasis::ExactName, + } + ); +} + +#[test] +fn a_trailing_space_never_claims_byte_equality() { + // `Bank ` against live `Bank` must not report exact: the import file would + // still carry the trailing space. Normalized is the correct, loud outcome — + // the write gate refuses it. + let catalog = ledgers(&["Bank"]); + let binding = bind_one_name(&catalog, "Bank "); + assert_eq!( + binding.status, + BindingStatus::Bound { + catalog_name: "Bank".to_string(), + basis: BindingBasis::NormalizedName, + } + ); + assert_eq!( + binding.source_name, "Bank ", + "the requested value is echoed verbatim" + ); +} + +#[test] +fn digits_inside_a_mixed_code_are_not_also_a_standalone_identifier() { + // Otherwise `Part AB12345678` collides with an unrelated `Bank 12345678`. + let entity = entity("Part AB12345678"); + assert_eq!( + entity.identifiers(), + [Identifier { + kind: IdentifierKind::Code, + value: "AB12345678".to_string(), + }] + ); + let catalog = ledgers(&["Bank 12345678", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Part AB12345678"); + assert_eq!( + binding.bound_name(), + None, + "a part code must not reach a bank ledger" + ); +} + +#[test] +fn an_eight_digit_date_in_any_admitted_order_is_not_an_identifier() { + for date in ["20260910", "01012026", "31122026", "12312026"] { + assert!( + entity(&format!("Period {date}")).identifiers().is_empty(), + "{date} was treated as an identifier" + ); + } + // A number that reads as no calendar date at all still is one. + assert_eq!(entity("Party 55500001").identifiers().len(), 1); +} + +#[test] +fn more_identifiers_than_the_bound_is_refused_not_truncated() { + // Keeping the first few can discard the identifier that pointed at a + // different master, turning a conflict into a bind. + let many = (0..MAX_IDENTIFIERS_PER_NAME + 1) + .map(|index| format!("5550{index:04}00")) + .collect::>() + .join(" "); + assert_eq!( + SourceEntity::new(0, &many), + Err(MasterBindingError::TooManyIdentifiers) + ); + assert_eq!( + MasterBindingError::TooManyIdentifiers.safe_reason_code(), + "master_identifiers_too_many" + ); +} + // --- candidate discipline -------------------------------------------------- #[test] @@ -367,18 +455,37 @@ fn a_catalog_wide_token_stops_discriminating() { } #[test] -fn candidates_are_capped_with_the_true_count_retained() { - let names = (0..MAX_CANDIDATES_PER_ENTITY + 5) +fn a_prefix_matching_a_whole_family_is_counted_and_deliberately_not_listed() { + // Measured against live books: listing an arbitrary capped slice of a name + // family put the right master out of view about a third of the time, + // because the slice is ordered by name and the family is uniform. Counting + // the family and listing none of it is the honest answer — the source name + // genuinely does not distinguish one from another. + let names = (0..MAX_PREFIX_FAMILY + 5) .map(|index| format!("ALPHAGROUP UNIT {index:02}")) .collect::>(); let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); let binding = bind_one_name(&catalog, "ALPHAGROUP"); let unresolved = binding.unresolved().expect("unbound"); - assert_eq!(unresolved.candidates.len(), MAX_CANDIDATES_PER_ENTITY); - assert_eq!(unresolved.candidate_count, MAX_CANDIDATES_PER_ENTITY + 5); + assert_eq!(reason(&binding), UnboundReason::NoDiscriminatingCandidate); + assert!(unresolved.candidates.is_empty()); + assert_eq!(unresolved.candidate_count, MAX_PREFIX_FAMILY + 5); assert!(unresolved.candidates_truncated); } +#[test] +fn a_family_within_the_bound_is_still_listed_in_full() { + let names = (0..MAX_PREFIX_FAMILY) + .map(|index| format!("ALPHAGROUP UNIT {index:02}")) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let binding = bind_one_name(&catalog, "ALPHAGROUP"); + let unresolved = binding.unresolved().expect("unbound"); + assert_eq!(reason(&binding), UnboundReason::NearMiss); + assert_eq!(unresolved.candidates.len(), MAX_PREFIX_FAMILY); + assert!(!unresolved.candidates_truncated); +} + #[test] fn candidate_order_is_rule_then_name_and_never_a_ranking() { let catalog = ledgers(&[ diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index f6eb39c7..e560b5c2 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -285,7 +285,15 @@ impl Server { &ledgers.into_iter().map(str::to_string).collect::>(), &catalogue, ) - .map_err(|code| ToolFailure::from(code).with_prior_evidence(identity_evidence.clone()))?; + // The catalogue read already succeeded, so its request/response + // commitments belong in the failure too; attaching identity evidence + // alone would omit a Tally read that actually happened. + .map_err(|code| { + ToolFailure::from(code).with_prior_evidence(combine_evidence( + identity_evidence.clone(), + evidence.clone(), + )) + })?; let hash = sha256_json(&catalogue); Ok(ToolOutcome { payload: json!({"company": company_json(&company, std::slice::from_ref(&company)), "result": {"masters": report, "catalogue_evidence_sha256": hash}}), diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index 66021e83..f42d5eec 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -1505,11 +1505,25 @@ fn master_match_bounds_suggestions_before_copying_names_and_preserves_ambiguity( .map(|index| format!("Ledger {index:03}")) .collect::>(); let borrowed = catalogue.iter().map(String::as_str).collect::>(); + // A name reaching a whole family distinguishes none of it. Measured against + // live books, listing an arbitrary capped slice omitted the right master + // about a third of the time, so the family is counted and not listed. let matched = one_master_match("Ledger", &borrowed); assert_eq!(matched["match_state"], "near_miss"); + assert_eq!( + matched["reason"], + "master_binding_no_discriminating_candidate" + ); assert_eq!(matched["candidate_count"], 100); assert_eq!(matched["candidates_truncated"], true); - assert_eq!(matched["candidates"].as_array().unwrap().len(), 25); + assert!(matched["candidates"].as_array().unwrap().is_empty()); + // A family inside the bound is still listed in full. + let small = (0..5) + .map(|index| format!("Small {index:02}")) + .collect::>(); + let small_ref = small.iter().map(String::as_str).collect::>(); + let listed = one_master_match("Small", &small_ref); + assert_eq!(listed["candidates"].as_array().unwrap().len(), 5); assert_eq!( one_master_match("Ledger 099", &borrowed)["match_state"], "exact" diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index 7505924e..ce2f3f38 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -55,6 +55,11 @@ pub(crate) struct SourceDraftCatalogTargets { pub(crate) source_sha256: String, pub(crate) targets: Vec, pub(crate) bindings: Vec, + /// `complete` when every source entry was bound, `unavailable` when the + /// narrowing pass could not run. An empty `bindings` list is otherwise + /// indistinguishable from a failed one, and the catalogue read itself still + /// succeeded. + pub(crate) bindings_state: &'static str, pub(crate) evidence: SourceDraftCatalogEvidence, } @@ -125,6 +130,9 @@ pub(super) struct CatalogApplySnapshot { pub(super) catalog: StandardLedgerCatalog, } +/// Total candidate-name bytes one catalogue-load response may carry. +const MAX_BINDING_CANDIDATE_BYTES: usize = 256 * 1024; + /// Binds every source entry's observed ledger name against the captured /// catalog. Advisory only: an empty or unusable capture narrows nothing rather /// than failing the read the operator just performed, and every returned name @@ -132,9 +140,9 @@ pub(super) struct CatalogApplySnapshot { fn source_entry_bindings( source: &crate::source_draft_xml::ParsedSource, targets: &[String], -) -> Vec { +) -> (Vec, &'static str) { let Ok(catalog) = MasterCatalog::new(MasterClass::Ledger, targets) else { - return Vec::new(); + return (Vec::new(), "unavailable"); }; let mut located = Vec::new(); let mut entities = Vec::new(); @@ -148,9 +156,16 @@ fn source_entry_bindings( } } let Ok(report) = master_binding::bind(&catalog, &entities) else { - return Vec::new(); + // A refusal is reported as such. Returning an empty list here would let + // a failed pass read exactly like a source that narrowed to nothing. + return (Vec::new(), "unavailable"); }; - report + // Candidate names are cloned per entry, so a large draft whose entries all + // share a prefix could otherwise build tens of megabytes of duplicate text + // before serialization. The budget is spent in source order and every entry + // still reports its true count. + let mut budget = MAX_BINDING_CANDIDATE_BYTES; + let bindings = report .entities() .iter() .zip(located) @@ -170,24 +185,31 @@ fn source_entry_bindings( candidates_truncated: false, }, BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved) => { + let mut candidates = Vec::new(); + for candidate in &unresolved.candidates { + let Some(remaining) = budget.checked_sub(candidate.catalog_name.len()) + else { + break; + }; + budget = remaining; + candidates.push(candidate.catalog_name.clone()); + } SourceDraftCatalogBinding { row_position, entry_position, bound_target: None, bound_basis: None, unbound_reason: Some(unresolved.reason.safe_reason_code()), - candidates: unresolved - .candidates - .iter() - .map(|candidate| candidate.catalog_name.clone()) - .collect(), + candidates_truncated: unresolved.candidates_truncated + || candidates.len() < unresolved.candidates.len(), + candidates, candidate_count: unresolved.candidate_count, - candidates_truncated: unresolved.candidates_truncated, } } }, ) - .collect() + .collect(); + (bindings, "complete") } pub(super) fn require_current_catalog_binding( @@ -334,7 +356,7 @@ impl SourceDraftStore { return Err(error("source_draft_catalogue_invalidated")); } let targets = read.catalog.names().map(str::to_owned).collect::>(); - let bindings = source_entry_bindings(¤t.source, &targets); + let (bindings, bindings_state) = source_entry_bindings(¤t.source, &targets); let capture = CatalogCapture { id: Uuid::new_v4(), draft_id: current.id, @@ -350,6 +372,7 @@ impl SourceDraftStore { source_sha256: capture.source_sha256.clone(), targets, bindings, + bindings_state, evidence: SourceDraftCatalogEvidence { request_sha256: read.request_sha256, response_sha256: read.response_sha256, @@ -545,7 +568,8 @@ mod tests { "GAMMA ALPHA".to_string(), "Beta Supply".to_string(), ]; - let bindings = source_entry_bindings(&fabricated_source(), &targets); + let (bindings, state) = source_entry_bindings(&fabricated_source(), &targets); + assert_eq!(state, "complete"); assert_eq!(bindings.len(), 3); // Case alone does not defeat a bind, and the live spelling is named. @@ -572,10 +596,13 @@ mod tests { } #[test] - fn narrowing_is_advisory_and_never_fails_a_completed_capture() { + fn a_narrowing_pass_that_could_not_run_says_so_rather_than_looking_empty() { // An unusable capture narrows nothing rather than discarding a read the - // operator just performed. The apply path still owns every refusal. - assert!(source_entry_bindings(&fabricated_source(), &[]).is_empty()); + // operator just performed — but "no bindings" and "binding failed" must + // not read alike, because the catalogue read itself still succeeded. + let (bindings, state) = source_entry_bindings(&fabricated_source(), &[]); + assert!(bindings.is_empty()); + assert_eq!(state, "unavailable"); } const CAPTURED_COMPANY: &str = "WR2 Unicode Lab"; diff --git a/src/source-draft-types.ts b/src/source-draft-types.ts index 9f80539f..54956ee6 100644 --- a/src/source-draft-types.ts +++ b/src/source-draft-types.ts @@ -79,6 +79,9 @@ export type SourceDraftCatalogTargets = { source_sha256: string; targets: string[]; bindings: SourceDraftCatalogBinding[]; + /// "complete" when every source entry was bound; "unavailable" when the + /// narrowing pass could not run. An empty list alone cannot say which. + bindings_state: "complete" | "unavailable"; evidence: { request_sha256: string; response_sha256: string; bytes: number; state: "complete" }; }; diff --git a/tools/bridge-tally-compatibility/src/lib.rs b/tools/bridge-tally-compatibility/src/lib.rs index 73d16f39..61dc5ad1 100644 --- a/tools/bridge-tally-compatibility/src/lib.rs +++ b/tools/bridge-tally-compatibility/src/lib.rs @@ -30,7 +30,15 @@ pub const RESERVED_SURFACE_FILES: usize = 15; /// reserved capacity covers a small cohesive feature (source, tests, docs /// and manifest) but makes further unreviewed additions an explicit /// compatibility-surface decision. -pub const MAX_SURFACE_FILES: usize = 210; +/// +/// Raised from 210 to 211 to admit +/// `src-tauri/crates/bridge-tally-core/src/master_binding.rs`. That file +/// decides `validate_masters` results and, through them, import admission; +/// left unpinned, an edit confined to the matcher would leave the surface +/// digest unchanged and let existing evidence attest behaviour it never +/// covered. This is the deliberate decision the paragraph above requires, and +/// it is one file for one named reason — not headroom. +pub const MAX_SURFACE_FILES: usize = 211; pub const MAX_OPERATIONS: usize = 16; pub const MAX_CLAIMS: usize = 128; pub const MAX_KEYS: usize = 32; @@ -2456,10 +2464,10 @@ mod tests { } #[test] - fn surface_file_cap_refuses_211_entries() { + fn surface_file_cap_refuses_one_more_than_the_cap() { let oversized = CompatibilitySurfaceManifest { schema_version: SURFACE_SCHEMA_VERSION, - files: (0..211) + files: (0..MAX_SURFACE_FILES + 1) .map(|index| SurfaceFile { path: format!("pinned-{index:03}"), sha256: "0".repeat(64), From eea460efe5c2487dc87946f5f53167b0a052bc4d Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Thu, 10 Sep 2026 23:54:45 +0530 Subject: [PATCH 05/75] Scope fallback assignment to the report it came from The remaining review finding, and the one I had triaged but not fixed. FallbackBinding::assign took an EntityBinding detached from its report plus any catalog, so a stock-item binding could be parked against a ledger catalog and the result carried no provenance for anything downstream to detect. Assignment is now a method on BindingReport taking an index into its own entities, so an entity from another report cannot be named at all, the catalog class is checked, and the binding carries its class forward. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 69 ++++++++++++------- .../src/master_binding_tests.rs | 37 ++++++++-- 4 files changed, 80 insertions(+), 32 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 1125d95d..63a66d27 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "09bc6b215b23e5a4feed7210e0289773a1f415846c714e918fa0d00d67c4c944", + "compatibility_surface_sha256": "49e84ae89b9fee801525eed75bdd5420f34400770354c77ee06e15449c371cde", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index d42e3eb7..500c05a4 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "253f74e93977d04c1744aab03608eb06de7acff70b85fe549a0b95a68f4b443c" + "sha256": "6d41ba230dc4e63a53018287480c9f3c3724d4df2592b6f56c284ed4d8df8d41" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "09bc6b215b23e5a4feed7210e0289773a1f415846c714e918fa0d00d67c4c944" + "manifest_sha256": "49e84ae89b9fee801525eed75bdd5420f34400770354c77ee06e15449c371cde" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index cb119426..adf04767 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -100,6 +100,9 @@ pub enum MasterBindingError { TooManyIdentifiers, #[error("fallback master was not a current catalog entry")] FallbackNotInCatalog, + /// A catalog of the wrong class, or an entity from another report. + #[error("catalog did not match the report it is used with")] + ClassMismatch, } impl MasterBindingError { @@ -116,6 +119,7 @@ impl MasterBindingError { Self::IdentifierHintUnusable => "master_identifier_hint_unusable", Self::TooManyIdentifiers => "master_identifiers_too_many", Self::FallbackNotInCatalog => "master_fallback_not_in_catalog", + Self::ClassMismatch => "master_class_mismatch", } } } @@ -323,6 +327,44 @@ impl BindingReport { .filter(|entity| !matches!(entity.status, BindingStatus::Bound { .. })) } + /// Parks one of *this report's* unbound entities against a fallback master + /// drawn from a catalog of the same class. + /// + /// Taking an index rather than an `EntityBinding` is the point: an entity + /// from another report — a stock-item binding, say — cannot be handed to a + /// ledger catalog, because it cannot be named here at all. The class is + /// then checked as well, so a same-shaped catalog of the wrong class is + /// refused rather than silently accepted, and the result carries the class + /// forward for anything downstream that needs to prove it. + pub fn assign_fallback( + &self, + entity_index: usize, + catalog: &MasterCatalog, + fallback_name: &str, + ) -> Result { + if catalog.class != self.class { + return Err(MasterBindingError::ClassMismatch); + } + let entity = self + .entities + .get(entity_index) + .ok_or(MasterBindingError::ClassMismatch)?; + let unresolved = entity + .unresolved() + .ok_or(MasterBindingError::FallbackNotInCatalog)?; + let fallback = catalog + .exact(fallback_name) + .ok_or(MasterBindingError::FallbackNotInCatalog)?; + Ok(FallbackBinding { + class: self.class, + position: entity.position, + source_name: entity.source_name.clone(), + fallback_name: fallback.to_string(), + retained: unresolved.unresolved_identity.clone(), + reason: unresolved.reason, + }) + } + pub fn totals(&self) -> BindingTotals { let mut totals = BindingTotals { requested: self.entities.len(), @@ -355,6 +397,7 @@ impl BindingReport { /// that already matched is not a representable state. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct FallbackBinding { + class: MasterClass, position: usize, source_name: String, fallback_name: String, @@ -363,29 +406,9 @@ pub struct FallbackBinding { } impl FallbackBinding { - /// Parks one unbound entity against a catalog-verified fallback master. - /// - /// Refuses a bound entity and refuses a fallback name that is not a current - /// catalog entry — a suspense ledger that does not exist is how one - /// engagement lost a batch. - pub fn assign( - entity: &EntityBinding, - catalog: &MasterCatalog, - fallback_name: &str, - ) -> Result { - let unresolved = entity - .unresolved() - .ok_or(MasterBindingError::FallbackNotInCatalog)?; - let fallback = catalog - .exact(fallback_name) - .ok_or(MasterBindingError::FallbackNotInCatalog)?; - Ok(Self { - position: entity.position, - source_name: entity.source_name.clone(), - fallback_name: fallback.to_string(), - retained: unresolved.unresolved_identity.clone(), - reason: unresolved.reason, - }) + /// The class of the catalog this fallback was drawn from. + pub fn class(&self) -> MasterClass { + self.class } pub fn position(&self) -> usize { diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 96e6fb10..b904e985 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -569,21 +569,23 @@ fn an_ambiguous_entity_parks_against_a_verified_fallback() { "BETA (5550000001)", "Suspense Placeholder", ]); - let binding = bind_one_name(&catalog, "PARTY 5550000001"); - let fallback = FallbackBinding::assign(&binding, &catalog, "Suspense Placeholder") + let report = bound(&catalog, &[entity("PARTY 5550000001")]); + let fallback = report + .assign_fallback(0, &catalog, "Suspense Placeholder") .expect("an unbound entity may be parked"); assert_eq!(fallback.fallback_name(), "Suspense Placeholder"); assert_eq!(fallback.source_name(), "PARTY 5550000001"); assert_eq!(fallback.reason(), UnboundReason::IdentifierConflict); assert_eq!(fallback.retained_tag(), "numeric:5550000001"); + assert_eq!(fallback.class(), MasterClass::Ledger); } #[test] fn a_bound_entity_cannot_be_parked() { let catalog = ledgers(&["Alpha Traders", "Suspense Placeholder"]); - let binding = bind_one_name(&catalog, "Alpha Traders"); + let report = bound(&catalog, &[entity("Alpha Traders")]); assert_eq!( - FallbackBinding::assign(&binding, &catalog, "Suspense Placeholder"), + report.assign_fallback(0, &catalog, "Suspense Placeholder"), Err(MasterBindingError::FallbackNotInCatalog) ); } @@ -592,13 +594,36 @@ fn a_bound_entity_cannot_be_parked() { fn a_fallback_master_that_does_not_exist_is_refused() { // A suspense ledger that was never created is how one batch was lost. let catalog = ledgers(&["Alpha Traders", "Beta Supply"]); - let binding = bind_one_name(&catalog, "Zeta Placeholder"); + let report = bound(&catalog, &[entity("Zeta Placeholder")]); assert_eq!( - FallbackBinding::assign(&binding, &catalog, "Suspense Placeholder"), + report.assign_fallback(0, &catalog, "Suspense Placeholder"), Err(MasterBindingError::FallbackNotInCatalog) ); } +#[test] +fn a_fallback_cannot_be_drawn_from_another_catalog_class_or_another_report() { + // A stock-item binding parked against a ledger catalog was a representable + // state that nothing downstream could detect. + let stock = MasterCatalog::new(MasterClass::StockItem, ["PH-01A-B00", "Scrap Placeholder"]) + .expect("valid"); + let ledger = ledgers(&["Alpha Traders", "Suspense Placeholder"]); + let stock_report = bound(&stock, &[entity("Zeta Placeholder")]); + assert_eq!( + stock_report.assign_fallback(0, &ledger, "Suspense Placeholder"), + Err(MasterBindingError::ClassMismatch) + ); + // An index outside this report cannot name another report's entity. + assert_eq!( + stock_report.assign_fallback(7, &stock, "Scrap Placeholder"), + Err(MasterBindingError::ClassMismatch) + ); + assert_eq!( + MasterBindingError::ClassMismatch.safe_reason_code(), + "master_class_mismatch" + ); +} + // --- the vocabulary is stable ---------------------------------------------- #[test] From 5dba3fe86b793486cc0aee116f27792df6739cf8 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 00:03:40 +0530 Subject: [PATCH 06/75] Address the four findings the fixes themselves generated A fix is a change and generates its own findings; the re-review of the previous commits raised four, all reproduced. An unusable ledger name in a parsed draft was skipped while the response still claimed a complete narrowing pass, so the rows that vanished were exactly the ones worth looking at; the pass is now reported unavailable. The reported candidate total took the larger of the suppressed family and the retained candidates, which under-reports when they are different masters; it is now their union. An identifier hint reached extraction without the bound applied to every other name. And the operator workflow in docs/agent/README.md still told readers to correct only near_miss rows, which now leaves a bound-but-not-exact row refused at the build. Co-Authored-By: Claude Opus 5 --- docs/agent/README.md | 16 +++++++-- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 ++-- .../bridge-tally-core/src/master_binding.rs | 36 +++++++++++++------ .../src/master_binding_tests.rs | 29 +++++++++++++++ src-tauri/src/source_draft/catalog.rs | 5 ++- 6 files changed, 76 insertions(+), 18 deletions(-) diff --git a/docs/agent/README.md b/docs/agent/README.md index f1ab7823..1568656e 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -189,8 +189,20 @@ type, host, licence mode, or manually imported file. 1. Call `voucher_schema` and produce a payload matching its schema. Transaction IDs are client-supplied, unique within the batch, and retained in the local import ledger. -2. Call `validate_masters` with every ledger name. Correct every `near_miss` - with the exact live spelling; Bridge never creates masters. +2. Call `validate_masters` with every ledger name. **`build_import_xml` admits + `exact` only**, so replace the payload name for every row that is not + `exact`, and never invent one: + - `normalized` or `identifier` — the row is bound. Copy its + `exact_live_spelling` into the payload verbatim; the live name may differ + from yours in case, spacing, dash or quote style, and the import file + carries whatever you send byte for byte. + - `near_miss` — the row is **not** bound and Bridge chose nothing. Pick from + `candidates`, each labelled with the rule that surfaced it. A single + candidate is still not a decision. Where `reason` is + `master_binding_no_discriminating_candidate`, the name reaches + `candidate_count` masters that it does not distinguish and none is listed; + use a more complete source name, or read the ledger list and choose. + - `missing` — no live ledger matched. Bridge never creates masters. 3. Call `build_import_xml` with the payload. It checks exact decimal balance, company date extent, live masters, and local journal integrity, repeats the full catalogue to reject intervening changes, then writes `/imports/.xml` and records an append-only diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 63a66d27..e7f373be 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "49e84ae89b9fee801525eed75bdd5420f34400770354c77ee06e15449c371cde", + "compatibility_surface_sha256": "bbe8d25be2c85a829a3056aa066aa5d9e21ff453800e598f6412f76d5f6dd2cb", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 500c05a4..3352cfda 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "6d41ba230dc4e63a53018287480c9f3c3724d4df2592b6f56c284ed4d8df8d41" + "sha256": "de8d6a702601b70fba774effa0842165393012f2cbc0a2c3f3afd7577daa049e" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -579,7 +579,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "53cfe9d0d248af44361b7d9cb314e301cc0dba87547c54bd59ea90ae35117325" + "sha256": "ceeb38b1c6055c5219678eafc4645b57b6655594cdd457ee97f440e690f31cdc" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "49e84ae89b9fee801525eed75bdd5420f34400770354c77ee06e15449c371cde" + "manifest_sha256": "bbe8d25be2c85a829a3056aa066aa5d9e21ff453800e598f6412f76d5f6dd2cb" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index adf04767..6ccca4ce 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -475,6 +475,9 @@ impl SourceEntity { let name = validated_name(name)?; let mut identifiers = extract_identifiers(&name)?; for hint in hints { + // A hint is caller-supplied document text like any other name, and + // must clear the same bound before anything scans or copies it. + validate_name_bounds(hint)?; let extracted = extract_identifiers(hint)?; if extracted.is_empty() { return Err(MasterBindingError::IdentifierHintUnusable); @@ -721,16 +724,16 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { &identifier_matches, ), None => { - let (candidates, prefix_family) = + let (candidates, masters_found) = collect_candidates(catalog, entity, &identifier_matches); let reason = if !candidates.is_empty() { UnboundReason::NearMiss - } else if prefix_family > MAX_PREFIX_FAMILY { + } else if masters_found > MAX_PREFIX_FAMILY { UnboundReason::NoDiscriminatingCandidate } else { UnboundReason::NoCandidate }; - unresolved_from(entity, reason, candidates, prefix_family) + unresolved_from(entity, reason, candidates, masters_found) } } }; @@ -749,21 +752,21 @@ fn unresolved_status( exact: Option, identifier_matches: &BTreeSet, ) -> BindingStatus { - let (mut candidates, prefix_family) = collect_candidates(catalog, entity, identifier_matches); + let (mut candidates, masters_found) = collect_candidates(catalog, entity, identifier_matches); if let Some(index) = exact { let name = catalog.entries[index].name.as_str(); if !candidates.iter().any(|(candidate, _)| candidate == name) { candidates.push((name.to_string(), CandidateRule::NormalizedEqual)); } } - unresolved_from(entity, reason, candidates, prefix_family) + unresolved_from(entity, reason, candidates, masters_found) } fn unresolved_from( entity: &SourceEntity, reason: UnboundReason, candidates: Vec<(String, CandidateRule)>, - prefix_family: usize, + masters_found: usize, ) -> BindingStatus { let mut ordered = candidates; ordered.sort_by(|left, right| { @@ -774,7 +777,7 @@ fn unresolved_from( }); // A suppressed family is still counted. The operator is told how many // masters the name reaches even when none of them is worth listing. - let candidate_count = ordered.len().max(prefix_family); + let candidate_count = ordered.len().max(masters_found); let candidates_truncated = candidate_count > ordered.len().min(MAX_CANDIDATES_PER_ENTITY); let candidates = ordered .into_iter() @@ -822,7 +825,7 @@ fn collect_candidates( offer(*index, CandidateRule::NormalizedEqual); } } - let mut prefix_family = 0_usize; + let mut suppressed_family: BTreeSet = BTreeSet::new(); if entity.key.chars().count() >= MIN_PREFIX_KEY_CHARS { // The key index is ordered, so both prefix directions are range or // point lookups rather than a scan of the whole catalog per entity. @@ -833,14 +836,15 @@ fn collect_candidates( .filter(|(key, _)| *key != &entity.key) .flat_map(|(_, holders)| holders.iter().copied()) .collect::>(); - prefix_family = extending.len(); // A prefix matching a whole family distinguishes nothing inside it, and // an arbitrary capped slice is worse than none: measured against live // books, that slice omitted the right master about a third of the time. - if prefix_family <= MAX_PREFIX_FAMILY { + if extending.len() <= MAX_PREFIX_FAMILY { for index in extending { offer(index, CandidateRule::CatalogPrefix); } + } else { + suppressed_family.extend(extending); } for split in MIN_PREFIX_KEY_CHARS..entity.key.len() { if !entity.key.is_char_boundary(split) { @@ -868,11 +872,21 @@ fn collect_candidates( } } + // The reported total is the union: a suppressed family and the candidates + // still worth listing are not necessarily the same masters, so taking the + // larger of the two counts would under-report what the name actually + // reaches. + let found = best + .keys() + .copied() + .chain(suppressed_family) + .collect::>() + .len(); ( best.into_iter() .map(|(index, rule)| (catalog.entries[index].name.clone(), rule)) .collect(), - prefix_family, + found, ) } diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index b904e985..077e4957 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -486,6 +486,35 @@ fn a_family_within_the_bound_is_still_listed_in_full() { assert!(!unresolved.candidates_truncated); } +#[test] +fn the_reported_count_is_the_union_of_suppressed_and_listed_candidates() { + // A suppressed family and the candidates still worth listing are not the + // same masters. Reporting the larger of the two counts under-reports what + // the name actually reaches, and candidate_count is promised as the total + // found before truncation. + let mut names = (0..MAX_PREFIX_FAMILY + 5) + .map(|index| format!("Alpha Beta {index:02}")) + .collect::>(); + names.push("Alpha".to_string()); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + + let binding = bind_one_name(&catalog, "Alpha Beta"); + let unresolved = binding.unresolved().expect("unbound"); + // The shorter master is still listed; the family behind it is not. + assert_eq!(candidate_names(&binding), ["Alpha"]); + assert_eq!(unresolved.candidate_count, MAX_PREFIX_FAMILY + 6); + assert!(unresolved.candidates_truncated); +} + +#[test] +fn an_identifier_hint_is_bounded_before_anything_scans_it() { + let huge = "5".repeat(MAX_NAME_CHARS + 1); + assert_eq!( + SourceEntity::with_identifier_hints(0, "Alpha Traders", [huge.as_str()]), + Err(MasterBindingError::NameTooLong) + ); +} + #[test] fn candidate_order_is_rule_then_name_and_never_a_ranking() { let catalog = ledgers(&[ diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index ce2f3f38..8c72b34d 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -148,8 +148,11 @@ fn source_entry_bindings( let mut entities = Vec::new(); for voucher in &source.vouchers { for entry in &voucher.entries { + // Dropping an unusable entry would return fewer bindings than the + // source has rows while still claiming completeness, and the row + // that vanished is exactly the one an operator needs to look at. let Ok(entity) = SourceEntity::new(entities.len(), &entry.ledger) else { - continue; + return (Vec::new(), "unavailable"); }; located.push((voucher.position, entry.position)); entities.push(entity); From acef951d5aab4088ed25f7b7602e9081d544e21b Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 00:11:17 +0530 Subject: [PATCH 07/75] Keep period labels out of code identifiers, and drop a quadratic scan Two further findings from the re-review, both reproduced. `Purchases FY25` and `Sales FY25` both yielded the code identifier `FY25`, so identifier-first matching bound the source to whichever existed before it ever compared the names. A fiscal-period label identifies a period, not a party, and is now excluded by shape; the minimum code length also rises from four to six, since a four-character mixed token is weak evidence of identity and the failure mode here is money against the wrong party. Re-measured against the same 470 live ledger names: no change to the distribution, so the tightening costs nothing observed. Candidate collection recounted every prefix from the start of the name, making it quadratic in a field the source parser lets reach 4 KiB. It now carries the character count forward in one pass. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 41 +++++++++++++++---- .../src/master_binding_tests.rs | 24 +++++++++++ 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index e7f373be..641e73b5 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "bbe8d25be2c85a829a3056aa066aa5d9e21ff453800e598f6412f76d5f6dd2cb", + "compatibility_surface_sha256": "eadfb92768999ec0081569cd3aeb292361e0f0066449ab7ac423650099553af9", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 3352cfda..77521646 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "de8d6a702601b70fba774effa0842165393012f2cbc0a2c3f3afd7577daa049e" + "sha256": "9c9ce0ddb6f8773c810616526fb05440e0d83b2fe82fd2ca85d07d4557862c10" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "bbe8d25be2c85a829a3056aa066aa5d9e21ff453800e598f6412f76d5f6dd2cb" + "manifest_sha256": "eadfb92768999ec0081569cd3aeb292361e0f0066449ab7ac423650099553af9" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 6ccca4ce..8550f51b 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -38,8 +38,10 @@ pub const MAX_IDENTIFIERS_PER_NAME: usize = 32; /// an account number and a customer code all clear it. pub const MIN_NUMERIC_IDENTIFIER_DIGITS: usize = 8; /// Alphanumeric characters a mixed letter-and-digit token needs before it is -/// treated as a code identifier. -pub const MIN_CODE_IDENTIFIER_CHARS: usize = 4; +/// treated as a code identifier. Six rather than four: a four-character mixed +/// token is weak evidence of identity, and the failure mode of a wrong +/// identifier is money against the wrong party. +pub const MIN_CODE_IDENTIFIER_CHARS: usize = 6; /// Digits a code identifier needs alongside at least one letter. pub const MIN_CODE_IDENTIFIER_DIGITS: usize = 2; /// Shortest comparison key that may take part in a prefix near-miss. @@ -846,15 +848,14 @@ fn collect_candidates( } else { suppressed_family.extend(extending); } - for split in MIN_PREFIX_KEY_CHARS..entity.key.len() { - if !entity.key.is_char_boundary(split) { + // One pass, carrying the character count forward. Recomputing + // `chars().count()` per prefix made this quadratic in the name length, + // and the source parser admits 4 KiB fields. + for (characters, (split, _)) in entity.key.char_indices().enumerate() { + if characters < MIN_PREFIX_KEY_CHARS { continue; } - let prefix = &entity.key[..split]; - if prefix.chars().count() < MIN_PREFIX_KEY_CHARS { - continue; - } - if let Some(holders) = catalog.by_key.get(prefix) { + if let Some(holders) = catalog.by_key.get(&entity.key[..split]) { for index in holders { offer(*index, CandidateRule::SourcePrefix); } @@ -969,6 +970,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro if canonical.len() >= MIN_CODE_IDENTIFIER_CHARS && digits >= MIN_CODE_IDENTIFIER_DIGITS && letters >= 1 + && !is_period_label(&canonical) { identifiers.insert(Identifier { kind: IdentifierKind::Code, @@ -995,6 +997,27 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro Ok(identifiers.into_iter().collect()) } +/// A fiscal-period label identifies a period, not a party or an item. Two +/// unrelated ledgers routinely share one — `Purchases FY2025` and +/// `Sales FY2025` — and identifier-first matching would bind the source to +/// whichever exists before it ever compared the names. +/// +/// This is a shape rule, not a vocabulary: it recognizes a short alphabetic +/// period marker followed only by digits, and like every other exclusion here +/// it can only make a bind *less* likely. +fn is_period_label(canonical: &str) -> bool { + let letters = canonical + .chars() + .take_while(|character| character.is_ascii_alphabetic()) + .collect::(); + let rest = &canonical[letters.len()..]; + matches!( + letters.as_str(), + "FY" | "AY" | "CY" | "Q" | "H" | "P" | "PER" | "FYE" + ) && !rest.is_empty() + && rest.chars().all(|character| character.is_ascii_digit()) +} + /// An eight-digit run that reads as a calendar date in any order this project /// admits is a date, not an identifier. Recognizing only `YYYYMMDD` left /// `01012026` binding a source to an unrelated master that shares its period diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 077e4957..fe7dbbce 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -410,6 +410,30 @@ fn digits_inside_a_mixed_code_are_not_also_a_standalone_identifier() { ); } +#[test] +fn a_fiscal_period_label_is_not_an_identity_bearing_code() { + // Two unrelated ledgers routinely share a period label. Identifier-first + // matching would otherwise bind the source to whichever one exists before + // it ever compared the names. + for label in ["FY25", "FY2025", "AY2026", "Q3", "H2", "PER2026"] { + assert!( + entity(&format!("Purchases {label}")) + .identifiers() + .is_empty(), + "{label} was treated as a code identifier" + ); + } + let catalog = ledgers(&["Sales FY2025", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Purchases FY2025"); + assert_eq!( + binding.bound_name(), + None, + "a shared period label must not bind two unrelated ledgers" + ); + // A genuine identity-bearing code still is one. + assert_eq!(entity("Item PH01AB00").identifiers().len(), 1); +} + #[test] fn an_eight_digit_date_in_any_admitted_order_is_not_an_identifier() { for date in ["20260910", "01012026", "31122026", "12312026"] { From aa9f4fb2ed63bc642c3b70bb83f12d3b03a1e80a Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 00:43:09 +0530 Subject: [PATCH 08/75] Seed identifier-bearing ledgers, and fix what they immediately exposed No book on the instance carried an embedded identifier: across 470 live ledger names from all 16 loaded companies, zero yielded a numeric identifier and exactly one a code identifier. The rule that separates this from fuzzy matching had no live coverage at all. Ten `MB ` ledgers now exist in BRIDGE CORPUS OPENING, parented to Suspense A/c so no receivable, payable or ageing measurement moves, and documented in TEST_CORPUS.md section 9 with the import method and the company-choice reasoning. BRIDGE PROBE B SANDBOX was rejected as the target despite the manufacturing precedent: it shares a GUID with a second loaded company and Bridge's own reads refuse it as company_identity_ambiguous. Within minutes the pair sharing one identifier exposed a defect no fabricated fixture had produced. A byte-exact request for a ledger whose embedded number is shared with another was refused as IdentifierConflict, making that ledger permanently unimportable, since the write gate admits exact only. Byte equality with an observed master name is now decisive: it names exactly one master, and an ambiguous identifier does not undermine it. Only a decisive identifier pointing elsewhere still outranks an exact name, and that stays a reported conflict. Re-measured over 485 live names, 2,330 cases: identifier binds 3 -> 11, every uppercase mutation now binds, and where candidates are listed the right master is present in 434 of 434 rows at a median list length of 2. Co-Authored-By: Claude Opus 5 --- docs/tally/TEST_CORPUS.md | 44 ++++++++++++++ .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 60 ++++++++++--------- .../src/master_binding_tests.rs | 40 +++++++++++++ 5 files changed, 120 insertions(+), 30 deletions(-) diff --git a/docs/tally/TEST_CORPUS.md b/docs/tally/TEST_CORPUS.md index 3476fd59..199b2d15 100644 --- a/docs/tally/TEST_CORPUS.md +++ b/docs/tally/TEST_CORPUS.md @@ -349,6 +349,50 @@ bytes, so it cannot support any claim about the exact bytes a real instance rece --- +## 9. Master-binding ledgers in `BRIDGE CORPUS OPENING` + +**Added 2026-09-10.** Ten ledgers prefixed `MB `, seeded so the master-binding +identifier rule has live coverage. Before this, **no book on either instance carried an +embedded identifier**: across 470 live ledger names read from all 16 loaded companies, +zero yielded a numeric identifier and exactly one yielded a code identifier. The rule that +distinguishes `bridge_tally_core::master_binding` from fuzzy matching was qualified by +fabricated data alone. + +| ledger | what it exercises | +| --- | --- | +| `MB PILOT ALPHA (5550001001)` | a unique embedded number | +| `MB PARTY BETA (5550001002)`, `MB PARTY GAMMA (5550001003)` | the same, for name-vs-identifier cases | +| `MB PARTY DELTA (5550001009)`, `MB PARTY EPSILON (5550001009)` | **two masters sharing one identifier** — must refuse, never bind | +| `MB ITEM PH01AB00` | a code identifier, matched across punctuation | +| `MB PURCHASES FY2025`, `MB SALES FY2025` | a shared fiscal-period label that must **not** be treated as an identifier | +| `MB TRADING COMPANY`, `MB TRADING COMPANY LIMITED` | a truncation / near-duplicate pair | + +**Chosen company.** `BRIDGE CORPUS OPENING` (GUID `915d42f8-42ae-4b03-8291-55f596e3a2ea`), +because it verifies as a single identity tuple and had only eight ledgers. **Not** +`BRIDGE PROBE B SANDBOX`, despite that being where corpus manufacturing was first proven: +it and `BRIDGE PROBE B SANDBOX - (from 1-Apr-26)` share one GUID, and Bridge's own read +path refuses that company with `company_identity_ambiguous`. Do not write to it by name. + +**Blast radius, deliberately small.** All ten are parented to `Suspense A/c`, which is not +a party group, so receivable/payable and ageing measurements on this book are unaffected. +They carry no opening balance and no vouchers. Every name is prefixed `MB `, so they are +trivially identifiable and removable. Master `AlterID` for this company did move; anything +pinning `ALTMSTID` for `BRIDGE CORPUS OPENING` predates 2026-09-10. + +**Import method.** `REPORTNAME=All Masters`, `ACTION="Create"`, one pilot ledger sent and +verified in the intended company *and confirmed absent from a guard company* before the +remaining nine. Counters were `CREATED=10, ALTERED=0, ERRORS=0`, and every name was +confirmed by a readback of the ledger list — counters alone prove nothing, since Tally +rewrites imports silently. **Do not re-send the create file:** an identical `Create` is a +silent `Alter` that overwrites. + +**What it found within minutes.** The `DELTA`/`EPSILON` pair exposed a defect no fabricated +fixture had produced: a *byte-exact* request for `MB PARTY DELTA (5550001009)` was being +refused as `IdentifierConflict`, because the number in its name is shared. That made the +ledger permanently unimportable, since the write gate admits `exact` only. Byte equality is +now decisive over an ambiguous identifier; only a *decisive* identifier pointing elsewhere +outranks an exact name. + ## 6. Changelog | Date | Change | diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 641e73b5..3dcd6097 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "eadfb92768999ec0081569cd3aeb292361e0f0066449ab7ac423650099553af9", + "compatibility_surface_sha256": "e9640c63aaf927cf0b55b9ca5d8fc5a306df0134449f13c54d4e18471b6a98b7", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 77521646..6d75b9b5 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "9c9ce0ddb6f8773c810616526fb05440e0d83b2fe82fd2ca85d07d4557862c10" + "sha256": "3cbb8f43d5f095e72819df7b9a997fdadbb9743e95ace24d8bd61f9f722842a1" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "eadfb92768999ec0081569cd3aeb292361e0f0066449ab7ac423650099553af9" + "manifest_sha256": "e9640c63aaf927cf0b55b9ca5d8fc5a306df0134449f13c54d4e18471b6a98b7" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 8550f51b..7330e628 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -674,44 +674,50 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { // An identifier shared by two masters, and an entity whose identifiers // reach two masters, are the same refusal: the operator has a naming // collision to see, and neither case licenses a choice. - let status = if identifier_conflict || identifier_matches.len() > 1 { + // Byte equality with an observed master name is the strongest evidence + // there is, and it names exactly one master. An identifier that happens to + // be ambiguous does not undermine it: refusing here would make a ledger + // whose embedded number is shared with another permanently unimportable — + // the same dead end that reporting `Identifier` for an exact name created. + // Only a *decisive* identifier pointing elsewhere outranks a byte-exact + // name, and that stays a reported conflict rather than a silent choice. + // + // Found by seeding two live ledgers that share an embedded number. No + // fabricated fixture had produced the combination. + let identifier_points_elsewhere = !identifier_conflict + && identifier_matches.len() == 1 + && exact.is_some_and(|index| !identifier_matches.contains(&index)); + let status = if identifier_points_elsewhere { unresolved_status( catalog, entity, - UnboundReason::IdentifierConflict, + UnboundReason::IdentifierNameConflict, exact, &identifier_matches, ) - } else if let Some(matched) = identifier_matches.iter().copied().next() { - // An identifier pointing at one master while the name exactly names - // another is a disagreement between two strong signals; it is shown, - // not silently decided in the identifier's favour. - match exact { - // Two strong signals disagreeing is shown, not settled. - Some(index) if index != matched => unresolved_status( - catalog, - entity, - UnboundReason::IdentifierNameConflict, - exact, - &identifier_matches, - ), - // They agree. Report the stronger, byte-level fact: the write gate - // admits `ExactName` only, and reporting `Identifier` here made - // every ledger carrying a number permanently unimportable. - Some(_) => BindingStatus::Bound { - catalog_name: catalog.entries[matched].name.clone(), - basis: BindingBasis::ExactName, - }, - None => BindingStatus::Bound { - catalog_name: catalog.entries[matched].name.clone(), - basis: BindingBasis::Identifier, - }, - } } else if let Some(index) = exact { BindingStatus::Bound { catalog_name: catalog.entries[index].name.clone(), basis: BindingBasis::ExactName, } + } else if identifier_conflict || identifier_matches.len() > 1 { + // An identifier shared by two masters, and an entity whose identifiers + // reach two masters, are the same refusal: the operator has a naming + // collision to see, and neither case licenses a choice. + unresolved_status( + catalog, + entity, + UnboundReason::IdentifierConflict, + exact, + &identifier_matches, + ) + } else if let Some(matched) = identifier_matches.iter().copied().next() { + // A decisive identifier, with no byte-exact name to outrank it. This is + // the rule that decided the case fuzzy matching got wrong. + BindingStatus::Bound { + catalog_name: catalog.entries[matched].name.clone(), + basis: BindingBasis::Identifier, + } } else { match catalog.by_key.get(&entity.key).map(Vec::as_slice) { Some([index]) => BindingStatus::Bound { diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index fe7dbbce..901e25d7 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -370,6 +370,46 @@ fn a_byte_exact_name_carrying_a_number_is_reported_exact_not_identifier() { ); } +#[test] +fn a_byte_exact_name_binds_even_when_its_identifier_is_shared() { + // Found by seeding two live ledgers that share an embedded number, which no + // fabricated fixture had combined. Refusing a name that exactly names one + // master makes that ledger permanently unimportable. + let catalog = ledgers(&[ + "MB PARTY DELTA (5550001009)", + "MB PARTY EPSILON (5550001009)", + "Beta Supply", + ]); + let binding = bind_one_name(&catalog, "MB PARTY DELTA (5550001009)"); + assert_eq!( + binding.status, + BindingStatus::Bound { + catalog_name: "MB PARTY DELTA (5550001009)".to_string(), + basis: BindingBasis::ExactName, + } + ); + // The shared identifier alone, with no exact name, still refuses. + let source = + SourceEntity::with_identifier_hints(0, "SOME PARTY", ["5550001009"]).expect("valid"); + let report = bound(&catalog, &[source]); + assert_eq!( + report.entities()[0].unresolved().expect("unbound").reason, + UnboundReason::IdentifierConflict + ); +} + +#[test] +fn a_decisive_identifier_pointing_elsewhere_still_outranks_a_byte_exact_name() { + let catalog = ledgers(&["ALPHA (5550000001)", "BETA Supply"]); + let source = + SourceEntity::with_identifier_hints(0, "BETA Supply", ["5550000001"]).expect("valid"); + let report = bound(&catalog, &[source]); + assert_eq!( + report.entities()[0].unresolved().expect("unbound").reason, + UnboundReason::IdentifierNameConflict + ); +} + #[test] fn a_trailing_space_never_claims_byte_equality() { // `Bank ` against live `Bank` must not report exact: the import file would From b87c8a3403d7dd2ca1bbacc96c9429f6ff8064f8 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 00:54:28 +0530 Subject: [PATCH 09/75] Recognize period labels by shape, and bound the report at its source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the latest re-review, both reproduced. Enumerating the period shapes that must not become identifiers was a losing game: FY25 was fixed, then APR2025 and 2025Q1 were still binding two unrelated ledgers that merely share a period. The rule is now a shape — every run in the token is a short alphabetic marker or a number reading as a year or small ordinal, at most three runs — and a code identifier additionally needs eight alphanumerics, three digits and two letters. Requiring real length is the part that does not depend on having thought of every label. Measured against 485 live ledger names, exactly one yields a code identifier at all, and it still does. The aggregate candidate budget was applied to the consumer's copy, so the report's own clones were already allocated by then; capping the copy bounded only the copy. The budget now lives in bind() and is spent in entity order, and the desktop's second budget is deleted as redundant. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 +- .../bridge-tally-core/src/master_binding.rs | 118 +++++++++++++----- .../src/master_binding_tests.rs | 45 ++++++- src-tauri/src/source_draft/catalog.rs | 29 ++--- 5 files changed, 141 insertions(+), 59 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 3dcd6097..becfab61 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "e9640c63aaf927cf0b55b9ca5d8fc5a306df0134449f13c54d4e18471b6a98b7", + "compatibility_surface_sha256": "acd1f802f722a8b8638ab8dafe5b708c55c2cc7469b724b9e21ab11df239b88b", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 6d75b9b5..02c7b767 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "3cbb8f43d5f095e72819df7b9a997fdadbb9743e95ace24d8bd61f9f722842a1" + "sha256": "ae31c5066579a46ad36c3bcffdb43cec4ac95e48ffa26c694c19aa871000a9c2" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -579,7 +579,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "ceeb38b1c6055c5219678eafc4645b57b6655594cdd457ee97f440e690f31cdc" + "sha256": "2ab349ed9b64ecea7442d604fc8e75c7824daf3cd5945e5c199c68c7097141ed" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "e9640c63aaf927cf0b55b9ca5d8fc5a306df0134449f13c54d4e18471b6a98b7" + "manifest_sha256": "acd1f802f722a8b8638ab8dafe5b708c55c2cc7469b724b9e21ab11df239b88b" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 7330e628..0c1bdd6c 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -30,6 +30,15 @@ pub const MAX_SOURCE_ENTITIES: usize = 40_000; pub const MAX_NAME_CHARS: usize = 16_384; /// Most candidates retained per unbound entity. pub const MAX_CANDIDATES_PER_ENTITY: usize = 25; +/// Total candidate-name bytes one report may allocate, across all entities. +/// +/// A per-entity cap does not bound a report: a draft the source parser admits +/// can carry tens of thousands of entries that each list 25 long names, and the +/// clones exist the moment the report is built. A consumer capping its own copy +/// afterwards bounds only the second copy. This is spent in entity order; +/// entities past it report their true `candidate_count` with no candidates +/// listed and truncation flagged. +pub const MAX_REPORT_CANDIDATE_BYTES: usize = 256 * 1024; /// Most identifiers one name may carry. Exceeding it is refused, never /// truncated. pub const MAX_IDENTIFIERS_PER_NAME: usize = 32; @@ -38,12 +47,18 @@ pub const MAX_IDENTIFIERS_PER_NAME: usize = 32; /// an account number and a customer code all clear it. pub const MIN_NUMERIC_IDENTIFIER_DIGITS: usize = 8; /// Alphanumeric characters a mixed letter-and-digit token needs before it is -/// treated as a code identifier. Six rather than four: a four-character mixed -/// token is weak evidence of identity, and the failure mode of a wrong -/// identifier is money against the wrong party. -pub const MIN_CODE_IDENTIFIER_CHARS: usize = 6; -/// Digits a code identifier needs alongside at least one letter. -pub const MIN_CODE_IDENTIFIER_DIGITS: usize = 2; +/// treated as a code identifier. +/// +/// Eight, raised twice under review. Enumerating the period shapes that must +/// not be identifiers — `FY25`, then `APR2025`, then `2025Q1` — is a losing +/// game, and each miss binds two unrelated ledgers that merely share a period. +/// Requiring real length is the rule that does not depend on having thought of +/// every label: a part number or registration code clears it, and a period +/// label does not. Measured against 485 live ledger names, exactly one yields a +/// code identifier at all, so this costs nothing observed. +pub const MIN_CODE_IDENTIFIER_CHARS: usize = 8; +/// Digits a code identifier needs alongside at least two letters. +pub const MIN_CODE_IDENTIFIER_DIGITS: usize = 3; /// Shortest comparison key that may take part in a prefix near-miss. pub const MIN_PREFIX_KEY_CHARS: usize = 3; /// Shortest token that may take part in a shared-token near-miss. @@ -645,16 +660,17 @@ pub fn bind( if entities.len() > MAX_SOURCE_ENTITIES { return Err(MasterBindingError::TooManySourceEntities); } + let mut budget = MAX_REPORT_CANDIDATE_BYTES; Ok(BindingReport { class: catalog.class, entities: entities .iter() - .map(|entity| bind_one(catalog, entity)) + .map(|entity| bind_one(catalog, entity, &mut budget)) .collect(), }) } -fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { +fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) -> EntityBinding { let exact = catalog.by_name.get(&entity.name).copied(); // Rule one: the identifier is the key, the name is a hint. A name @@ -694,6 +710,7 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { UnboundReason::IdentifierNameConflict, exact, &identifier_matches, + budget, ) } else if let Some(index) = exact { BindingStatus::Bound { @@ -710,6 +727,7 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { UnboundReason::IdentifierConflict, exact, &identifier_matches, + budget, ) } else if let Some(matched) = identifier_matches.iter().copied().next() { // A decisive identifier, with no byte-exact name to outrank it. This is @@ -730,6 +748,7 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { UnboundReason::NameAmbiguous, exact, &identifier_matches, + budget, ), None => { let (candidates, masters_found) = @@ -741,7 +760,7 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { } else { UnboundReason::NoCandidate }; - unresolved_from(entity, reason, candidates, masters_found) + unresolved_from(entity, reason, candidates, masters_found, budget) } } }; @@ -759,6 +778,7 @@ fn unresolved_status( reason: UnboundReason, exact: Option, identifier_matches: &BTreeSet, + budget: &mut usize, ) -> BindingStatus { let (mut candidates, masters_found) = collect_candidates(catalog, entity, identifier_matches); if let Some(index) = exact { @@ -767,7 +787,7 @@ fn unresolved_status( candidates.push((name.to_string(), CandidateRule::NormalizedEqual)); } } - unresolved_from(entity, reason, candidates, masters_found) + unresolved_from(entity, reason, candidates, masters_found, budget) } fn unresolved_from( @@ -775,6 +795,7 @@ fn unresolved_from( reason: UnboundReason, candidates: Vec<(String, CandidateRule)>, masters_found: usize, + budget: &mut usize, ) -> BindingStatus { let mut ordered = candidates; ordered.sort_by(|left, right| { @@ -786,18 +807,21 @@ fn unresolved_from( // A suppressed family is still counted. The operator is told how many // masters the name reaches even when none of them is worth listing. let candidate_count = ordered.len().max(masters_found); - let candidates_truncated = candidate_count > ordered.len().min(MAX_CANDIDATES_PER_ENTITY); + let listed = ordered.len().min(MAX_CANDIDATES_PER_ENTITY); let candidates = ordered .into_iter() .take(MAX_CANDIDATES_PER_ENTITY) - .map(|(catalog_name, rule)| Candidate { catalog_name, rule }) - .collect(); + .map_while(|(catalog_name, rule)| { + *budget = budget.checked_sub(catalog_name.len())?; + Some(Candidate { catalog_name, rule }) + }) + .collect::>(); let unresolved = Unresolved { reason, unresolved_identity: entity.identifiers.clone(), - candidates, candidate_count, - candidates_truncated, + candidates_truncated: candidate_count > listed || candidates.len() < listed, + candidates, }; if matches!(reason, UnboundReason::NoCandidate) { BindingStatus::Unmatched(unresolved) @@ -975,7 +999,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro let letters = canonical.chars().filter(char::is_ascii_alphabetic).count(); if canonical.len() >= MIN_CODE_IDENTIFIER_CHARS && digits >= MIN_CODE_IDENTIFIER_DIGITS - && letters >= 1 + && letters >= 2 && !is_period_label(&canonical) { identifiers.insert(Identifier { @@ -1003,28 +1027,54 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro Ok(identifiers.into_iter().collect()) } -/// A fiscal-period label identifies a period, not a party or an item. Two -/// unrelated ledgers routinely share one — `Purchases FY2025` and -/// `Sales FY2025` — and identifier-first matching would bind the source to -/// whichever exists before it ever compared the names. +/// A period label identifies a period, not a party or an item. Two unrelated +/// ledgers routinely share one — `Purchases FY2025` and `Sales FY2025`, +/// `Purchases APR2025` and `Sales APR2025` — and identifier-first matching +/// would bind the source to whichever exists before it compared the names. +/// +/// Recognized by *shape* rather than by a vocabulary of prefixes, because a +/// list of prefixes kept missing one more spelling: every run in the token is +/// either a short alphabetic marker or a number that reads as a year or a +/// small ordinal, and there are at most three runs. `FY2025`, `APR2025`, +/// `2025Q1` and `Q3` all match; `PH01AB00` and `AB12345678` do not. /// -/// This is a shape rule, not a vocabulary: it recognizes a short alphabetic -/// period marker followed only by digits, and like every other exclusion here -/// it can only make a bind *less* likely. +/// Like every exclusion here it can only make a bind *less* likely. fn is_period_label(canonical: &str) -> bool { - let letters = canonical - .chars() - .take_while(|character| character.is_ascii_alphabetic()) - .collect::(); - let rest = &canonical[letters.len()..]; - matches!( - letters.as_str(), - "FY" | "AY" | "CY" | "Q" | "H" | "P" | "PER" | "FYE" - ) && !rest.is_empty() - && rest.chars().all(|character| character.is_ascii_digit()) + let mut runs = 0_usize; + let mut has_period_number = false; + let mut rest = canonical; + while !rest.is_empty() { + runs += 1; + if runs > 3 { + return false; + } + let alphabetic = rest.starts_with(|character: char| character.is_ascii_alphabetic()); + let split = rest + .find(|character: char| character.is_ascii_alphabetic() != alphabetic) + .unwrap_or(rest.len()); + let (run, tail) = rest.split_at(split); + rest = tail; + if alphabetic { + if run.len() > 4 { + return false; + } + } else { + let value = run.parse::().unwrap_or(u32::MAX); + let reads_as_period = match run.len() { + 1 | 2 => (1..=99).contains(&value), + 4 => (1900..=2199).contains(&value), + _ => false, + }; + if !reads_as_period { + return false; + } + has_period_number = true; + } + } + has_period_number } -/// An eight-digit run that reads as a calendar date in any order this project +/// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project /// admits is a date, not an identifier. Recognizing only `YYYYMMDD` left /// `01012026` binding a source to an unrelated master that shares its period /// label. Being generous here can only make a bind *less* likely, which is the diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 901e25d7..68e6b842 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -455,7 +455,9 @@ fn a_fiscal_period_label_is_not_an_identity_bearing_code() { // Two unrelated ledgers routinely share a period label. Identifier-first // matching would otherwise bind the source to whichever one exists before // it ever compared the names. - for label in ["FY25", "FY2025", "AY2026", "Q3", "H2", "PER2026"] { + for label in [ + "FY25", "FY2025", "AY2026", "Q3", "H2", "PER2026", "APR2025", "2025Q1", "MAR26", "H12026", + ] { assert!( entity(&format!("Purchases {label}")) .identifiers() @@ -474,6 +476,47 @@ fn a_fiscal_period_label_is_not_an_identity_bearing_code() { assert_eq!(entity("Item PH01AB00").identifiers().len(), 1); } +#[test] +fn a_report_bounds_its_own_candidate_allocation() { + // A per-entity cap does not bound a report: the clones exist the moment it + // is built, and a consumer capping its own copy afterwards bounds only the + // copy. The budget is spent in entity order; entities past it keep their + // true count and flag truncation. + let long = "Z".repeat(400); + let names = (0..30) + .map(|index| format!("SHARED PREFIX {index:03} {long}")) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let entities = (0..2_000) + .map(|position| SourceEntity::new(position, "SHARED PREFIX 001").expect("valid")) + .collect::>(); + let report = bound(&catalog, &entities); + let listed: usize = report + .unbound() + .filter_map(|entity| entity.unresolved()) + .map(|unresolved| { + unresolved + .candidates + .iter() + .map(|candidate| candidate.catalog_name.len()) + .sum::() + }) + .sum(); + assert!( + listed <= MAX_REPORT_CANDIDATE_BYTES, + "report allocated {listed} candidate bytes" + ); + let starved = report + .unbound() + .filter_map(|entity| entity.unresolved()) + .filter(|unresolved| unresolved.candidates.is_empty()) + .collect::>(); + assert!(!starved.is_empty(), "the budget must actually bite here"); + assert!(starved + .iter() + .all(|unresolved| unresolved.candidate_count > 0 && unresolved.candidates_truncated)); +} + #[test] fn an_eight_digit_date_in_any_admitted_order_is_not_an_identifier() { for date in ["20260910", "01012026", "31122026", "12312026"] { diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index 8c72b34d..dd3cae80 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -130,9 +130,6 @@ pub(super) struct CatalogApplySnapshot { pub(super) catalog: StandardLedgerCatalog, } -/// Total candidate-name bytes one catalogue-load response may carry. -const MAX_BINDING_CANDIDATE_BYTES: usize = 256 * 1024; - /// Binds every source entry's observed ledger name against the captured /// catalog. Advisory only: an empty or unusable capture narrows nothing rather /// than failing the read the operator just performed, and every returned name @@ -163,11 +160,9 @@ fn source_entry_bindings( // a failed pass read exactly like a source that narrowed to nothing. return (Vec::new(), "unavailable"); }; - // Candidate names are cloned per entry, so a large draft whose entries all - // share a prefix could otherwise build tens of megabytes of duplicate text - // before serialization. The budget is spent in source order and every entry - // still reports its true count. - let mut budget = MAX_BINDING_CANDIDATE_BYTES; + // The report itself is bounded by `MAX_REPORT_CANDIDATE_BYTES`, so this + // path no longer needs a second budget of its own: capping the copy left + // the original allocation unbounded, which was the actual stall risk. let bindings = report .entities() .iter() @@ -188,24 +183,18 @@ fn source_entry_bindings( candidates_truncated: false, }, BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved) => { - let mut candidates = Vec::new(); - for candidate in &unresolved.candidates { - let Some(remaining) = budget.checked_sub(candidate.catalog_name.len()) - else { - break; - }; - budget = remaining; - candidates.push(candidate.catalog_name.clone()); - } SourceDraftCatalogBinding { row_position, entry_position, bound_target: None, bound_basis: None, unbound_reason: Some(unresolved.reason.safe_reason_code()), - candidates_truncated: unresolved.candidates_truncated - || candidates.len() < unresolved.candidates.len(), - candidates, + candidates_truncated: unresolved.candidates_truncated, + candidates: unresolved + .candidates + .iter() + .map(|candidate| candidate.catalog_name.clone()) + .collect(), candidate_count: unresolved.candidate_count, } } From 03280f2b5aa9d8091fa1d2f49991059a2da86209 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:26:53 +0530 Subject: [PATCH 10/75] Make the comparison key an explicit contract point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The voucher-presence lane needs the same fold for voucher numbers and voucher-type names that master names use, and is exposing this function crate-wide to get it. That is the right call — a second, subtly different normaliser is the divergence ADR 0016 exists to end, and it would diverge silently, agreeing on every name tested by hand and differing on the punctuation nobody thinks to try. Records that obligation at the function, and its corollary: changing what this folds changes every consumer's notion of sameness at once. Co-Authored-By: Claude Opus 5 --- docs/tally/compatibility/compatibility-matrix.json | 2 +- .../tally/compatibility/compatibility-surface.json | 4 ++-- .../crates/bridge-tally-core/src/master_binding.rs | 14 +++++++++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index becfab61..e8d50969 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "acd1f802f722a8b8638ab8dafe5b708c55c2cc7469b724b9e21ab11df239b88b", + "compatibility_surface_sha256": "f86d7446936b1f953e18912ea1c44a7e379ef32df065b3846fb9a5a2d78e314c", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 02c7b767..e05ad488 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "ae31c5066579a46ad36c3bcffdb43cec4ac95e48ffa26c694c19aa871000a9c2" + "sha256": "f90c94916acfe7c581d020765062f1906564daab1b745a700a221633e4f60ccb" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "acd1f802f722a8b8638ab8dafe5b708c55c2cc7469b724b9e21ab11df239b88b" + "manifest_sha256": "f86d7446936b1f953e18912ea1c44a7e379ef32df065b3846fb9a5a2d78e314c" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 0c1bdd6c..2fccae8e 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -952,7 +952,19 @@ fn validate_name_bounds(value: &str) -> Result<(), MasterBindingError> { /// Folds the punctuation an operator happened to type: NFC-equivalent dash and /// quote variants become ASCII, case is lowered, whitespace runs collapse. /// Nothing else is folded — no stemming, no transliteration, no vowel removal. -fn comparison_key(value: &str) -> String { +/// +/// **This is a contract, not an implementation detail.** Anything in this crate +/// that decides whether two operator-typed strings are "the same" — master +/// names here, and voucher numbers or voucher-type names elsewhere — must fold +/// through this one function. A second, subtly different normaliser is exactly +/// the divergence ADR 0016 exists to end, and it would diverge silently: +/// the two agree on every name anyone tests by hand and disagree on the +/// punctuation nobody thinks to try. +/// +/// The corollary is that changing what this folds changes every consumer's +/// notion of sameness at once. Widen it only with the same care as a wire +/// format, and never to make one caller's case pass. +pub(crate) fn comparison_key(value: &str) -> String { value .nfc() .flat_map(|character| match character { From e925f79d4096dc210706d15ebec654a3173405d5 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:39:48 +0530 Subject: [PATCH 11/75] Say what an undiscriminable source line actually means MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The narrowing copy predated the state the live measurement made necessary. For a source name that reaches a family of ledgers and tells none of them apart, the report now lists no candidates and reports the count — so the screen would have rendered "0 possible ledgers are listed first", a count of nothing, over the case that matters most. That state now says what it means: the line matches N existing ledgers and separates none of them, so none is listed; use a fuller source name or pick from the full list. No misleading "Possible" heading appears over an empty group, and the whole catalogue stays reachable as before. Also surfaces bindings_state: when the narrowing pass could not run, the toolbar says so rather than letting every row look merely unnarrowed. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- scripts/source-draft-screen.test.tsx | 51 +++++++++++++++++++ src/SourceDraftScreen.tsx | 9 +++- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index f3261cff..c6f7cff4 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "d711122b10dce9431ce31e4084d56597daf52566805c5242143f8fb7049de3b0", + "compatibility_surface_sha256": "65fd2f738ef0c3c29055cb143da2f37e5713768293b20ed42ca7ca21e9704d28", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 168ac281..c6cdcf74 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -719,7 +719,7 @@ }, { "path": "src/SourceDraftScreen.tsx", - "sha256": "d81fcda5ae03cf8fa23961397118f0d977d5db274e972006d84c2b0e70fdecc0" + "sha256": "b7693a1d75749f499e19219b346ecf724bebf8d73d4af21a1b55b2170ea47836" }, { "path": "src/TallyReadinessFlow.tsx", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "d711122b10dce9431ce31e4084d56597daf52566805c5242143f8fb7049de3b0" + "manifest_sha256": "65fd2f738ef0c3c29055cb143da2f37e5713768293b20ed42ca7ca21e9704d28" } \ No newline at end of file diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index 4edcd3bd..4ec2382a 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -50,6 +50,7 @@ const catalog = { capture_id: "00000000-0000-4000-8000-000000000099", source_sha256: draft.source_sha256, targets: ["Existing target"], + bindings_state: "complete" as const, evidence: { request_sha256: "b".repeat(64), response_sha256: "c".repeat(64), bytes: 100, state: "complete" as const }, }; @@ -402,6 +403,56 @@ test("reports a truncated candidate list truthfully and falls back to the flat c root.unmount(); }); +test("a source line that separates no ledger says so instead of counting nothing", async () => { + // The state the live measurement made necessary: the name reaches a whole + // family and tells none of them apart, so listing an arbitrary slice would + // put the right one out of view. The old copy printed "0 possible ledgers + // are listed first", which is a count of nothing. + const familyCatalog = { + ...catalog, + targets: ["DN Party 001", "DN Party 002", "DN Party 003"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: null, + bound_basis: null, + unbound_reason: "master_binding_no_discriminating_candidate", + candidates: [], + candidate_count: 120, + candidates_truncated: true, + }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(familyCatalog); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + + expect(host.textContent).toContain("matches 120 existing ledgers and tells them apart from none of them, so none is listed"); + expect(host.textContent).not.toContain("0 possible"); + expect(host.textContent).not.toContain("listed first;"); + const target = host.querySelector("#source-draft-1-entry-0-ledger")!; + // No misleading "Possible" heading over an empty group, and the full list stays. + const groups = Array.from(target.querySelectorAll("optgroup")).map((group) => group.label); + expect(groups).toEqual(["Existing ledgers"]); + expect(Array.from(target.querySelectorAll("option")).map((option) => option.value)) + .toEqual(["", "DN Party 001", "DN Party 002", "DN Party 003"]); + root.unmount(); +}); + +test("a capture whose narrowing could not run says so rather than looking unnarrowed", async () => { + const unavailable = { ...catalog, bindings: [], bindings_state: "unavailable" as const }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(unavailable); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + expect(host.textContent).toContain("could not narrow this source's lines, so every row lists the full catalogue"); + root.unmount(); +}); + test("a capture without bindings still renders the whole catalogue and claims nothing", async () => { // An older capture, or one the backend could not narrow, must not lose the // list the operator came for. diff --git a/src/SourceDraftScreen.tsx b/src/SourceDraftScreen.tsx index 702db428..ae552fe4 100644 --- a/src/SourceDraftScreen.tsx +++ b/src/SourceDraftScreen.tsx @@ -93,6 +93,13 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: return `No existing ledger matched this source line. All ${total} are listed.`; } const shown = binding.candidates.length; + if (shown === 0) { + // Measured against live books: this source name reaches a whole family of + // ledgers and separates none of them, so listing an arbitrary slice put the + // right one out of view about a third of the time. Say that, rather than + // print a count of nothing. + return `This source line matches ${binding.candidate_count} existing ledgers and tells them apart from none of them, so none is listed. Use a fuller source name, or choose from the full list of ${total}.`; + } const listed = binding.candidates_truncated ? `${shown} of ${binding.candidate_count}` : `${shown}`; return `No single ledger matched this source line, so nothing is chosen. ${listed} possible ${shown === 1 ? "ledger is" : "ledgers are"} listed first; the full list of ${total} follows.`; } @@ -456,7 +463,7 @@ export function SourceDraftScreen({ <>
{draft.source_filename}{draft.rows.length} source rows · {rowsWithoutProposal} rows without a proposal · revision {draft.revision}

Source values are immutable observations. A target chosen from the current ledger list remains an unverified proposal; this preparation screen cannot approve or post anything to Tally.

-
{!catalogScope && Check Tally and select a current company before loading existing ledgers.}{catalogInvalidating && Existing-ledger context is changing.}{catalog && {catalog.targets.length} existing ledgers captured for this source. Choosing one remains unverified.}
+
{!catalogScope && Check Tally and select a current company before loading existing ledgers.}{catalogInvalidating && Existing-ledger context is changing.}{catalog && {catalog.targets.length} existing ledgers captured for this source. Choosing one remains unverified.{catalog.bindings_state === "unavailable" ? " Bridge could not narrow this source's lines, so every row lists the full catalogue." : ""}}
Source file evidence
Source file
{draft.source_filename}
Source SHA-256
{draft.source_sha256}
{((draft.source_notices ?? []).length > 0) &&
Source-level notices ({(draft.source_notices ?? []).length})
    {(draft.source_notices ?? []).map((notice, index) =>
  • {notice.kind}{notice.count} retained records
  • )}
} {savedPath &&

{savedPath}

} From eaf3dd7ae81cd4576305e4d9ef357b19c6d52994 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:48:40 +0530 Subject: [PATCH 12/75] Name the channel a parked identity must travel in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client-supplied REMOTEID is not readable back — Tally overwrites the attribute with its own value (IMPLEMENTATION_GUIDE.md §3.3a, fourth property, verified). Nothing here uses it, but the doc on unresolved_identity said only that the identity is retained "for later reallocation" without naming the channel, and the obvious wrong choice fails silently: an amount parked with its identity in a write-only field is unreallocatable, and nothing about the write says so. Says narration, and says why. Surfaced by the voucher-presence lane, which had to correct three claims of its own after reading the reference's §9.3 heading as the general case. Co-Authored-By: Claude Opus 5 --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- src-tauri/crates/bridge-tally-core/src/master_binding.rs | 8 ++++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index e8d50969..5ac5de99 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "f86d7446936b1f953e18912ea1c44a7e379ef32df065b3846fb9a5a2d78e314c", + "compatibility_surface_sha256": "b3c3b808259cdfb7530d489ee389b760708313de9e49c9ed019bf7d22dd0856d", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index e05ad488..fdcfed42 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "f90c94916acfe7c581d020765062f1906564daab1b745a700a221633e4f60ccb" + "sha256": "adc4318c06e337bff245014f3f585aaad47cc4d6c6a5988c4221b656b51c0b62" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "f86d7446936b1f953e18912ea1c44a7e379ef32df065b3846fb9a5a2d78e314c" + "manifest_sha256": "b3c3b808259cdfb7530d489ee389b760708313de9e49c9ed019bf7d22dd0856d" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 2fccae8e..6a478403 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -254,6 +254,14 @@ pub struct Unresolved { /// Identifiers extracted from the source name and from caller hints, /// retained so a fallback posting can be reallocated later without /// re-reading the source document. + /// + /// **This must travel in a channel that survives a read back — the + /// narration.** A client-supplied `REMOTEID` is not it: Tally overwrites + /// the attribute with its own value, so a key written there cannot be + /// observed afterwards and cannot identify what to reallocate + /// (`docs/tally/IMPLEMENTATION_GUIDE.md` §3.3a, fourth property, verified). + /// A parked amount whose identity went into a write-only field is + /// unreallocatable, and nothing about the write would say so. pub unresolved_identity: Vec, pub candidates: Vec, /// Candidates found before truncation. From 46a157d698a3e135b9476f8c972f8ad063abf598 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:53:50 +0530 Subject: [PATCH 13/75] Say in the producer's contract that an empty candidate list is three facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit candidates can be empty because nothing resembles the name, because a family resembles it and none is separable, or because the list was cut — and those mean opposite things to whoever decides what to do next. The disambiguators are reason, candidate_count and candidates_truncated, and reading the empty vector alone is wrong in two cases out of three. Recorded here rather than left to each consumer because it has already been got wrong twice by different lanes: the preparation screen rendered "0 possible ledgers are listed first" over a family of 120, and the voucher-presence contract needed a paired test to stop its rule collapsing into "no candidates means unknown". Same defect class this ADR was written against — a refusal whose neighbouring value reads as an answer. Also records why this is a doc and not a type. An enum of Listed / Truncated / Withheld / None is the stronger fix and the one P2 asks for, but it is breaking, a stacked consumer already depends on candidates_truncated as a predicate and holds the boundary with tests, and forcing that rework mid-review trades an improvement for a regression risk. Revisit once both have merged. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 37 +++++++++++++++++++ .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 12 +++++- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 61692393..b9aff3a6 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -155,6 +155,43 @@ Candidates are capped at `MAX_CANDIDATES_PER_ENTITY` (25) with the true `candidate_count` and an explicit `candidates_truncated` flag retained, so a truncated list is never mistaken for a short one. +### 4a. An empty candidate list is three different facts, and the producer says which + +`candidates` can be empty for three unrelated reasons, and they mean opposite +things to anyone deciding what to do next: + +| `reason` | what empty means | +| --- | --- | +| `NoCandidate` | no master resembles this name at all | +| `NoDiscriminatingCandidate` | `candidate_count` masters resemble it and none is separable — **many exist**, none is worth showing | +| any, with `candidates_truncated` | the list was cut, by the per-entity cap or by the report's aggregate byte budget | + +So `candidates.is_empty()` alone answers nothing. The disambiguators are +`reason`, `candidate_count` and `candidates_truncated`, and a consumer that +reads the empty vector as "nothing exists" is wrong in two cases out of three. + +This is stated here, in the producer's contract, rather than left to each +consumer to rediscover, because **it has already been got wrong twice by +different lanes**: the preparation screen rendered "0 possible ledgers are +listed first" over a family of 120, and the voucher-presence contract had to +add a paired test to stop its own rule collapsing into "no candidates means +unknown" — a reading that is right for the truncated case and wrong for +`NoCandidate`. + +It is the same defect class this ADR was written against: a refusal whose +neighbouring value reads as an answer. The vocabulary is deliberately explicit +so that "nothing survived to be shown" and "nothing exists" cannot be confused +by reading one field. + +**Why this is a doc and not a type.** Making the four cases unrepresentable — +an enum of `Listed` / `Truncated` / `Withheld` / `None` rather than a vector +plus two flags — would be the stronger fix and is the one P2 asks for. It is +deliberately deferred: the change is breaking, a stacked consumer already +depends on `candidates_truncated` as a predicate and holds the boundary with +tests, and forcing that rework while this contract is under review trades a +real improvement for a real regression risk. It should be revisited once this +and its dependent have merged. + ### 5. Status vocabulary Per entity, exactly one of: diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 5ac5de99..aaada3ce 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "b3c3b808259cdfb7530d489ee389b760708313de9e49c9ed019bf7d22dd0856d", + "compatibility_surface_sha256": "1785bffb059d601272542b55b55ffc9b47fc46798860182fd21704283493597b", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index fdcfed42..18204a3b 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "adc4318c06e337bff245014f3f585aaad47cc4d6c6a5988c4221b656b51c0b62" + "sha256": "2b45787586520e9bbb33bc2bcbb1384229fde5919e9d3cbca901f44de241080b" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "b3c3b808259cdfb7530d489ee389b760708313de9e49c9ed019bf7d22dd0856d" + "manifest_sha256": "1785bffb059d601272542b55b55ffc9b47fc46798860182fd21704283493597b" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 6a478403..8f24848f 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -263,8 +263,18 @@ pub struct Unresolved { /// A parked amount whose identity went into a write-only field is /// unreallocatable, and nothing about the write would say so. pub unresolved_identity: Vec, + /// The masters worth showing, most defensible first. + /// + /// **Empty is three different facts.** With `NoCandidate` it means nothing + /// resembles this name; with `NoDiscriminatingCandidate` it means + /// `candidate_count` masters resemble it and none is separable; with + /// `candidates_truncated` it means the list was cut, by the per-entity cap + /// or the report's aggregate byte budget. Reading the empty vector as + /// "nothing exists" is wrong in two of the three. Disambiguate on `reason` + /// and `candidates_truncated` — see ADR 0016 §4a. pub candidates: Vec, - /// Candidates found before truncation. + /// Masters found before any truncation, including a family that was + /// counted and deliberately not listed. pub candidate_count: usize, pub candidates_truncated: bool, } From a6d95adebed98938ce9ad3ad601fa9612be5be27 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 02:41:20 +0530 Subject: [PATCH 14/75] Follow Tally's own rule for master sameness, and type the candidate listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five items in one round, because review reopens once either way and the marginal cost of the rest once it is reopened is small. Tally's master-name matching is measured, not guessable: IMPLEMENTATION_GUIDE.md §3.3b found it case-insensitive AND separator-insensitive — a hyphen matches a space — and otherwise exact. The binder was stricter, which is not the safe direction it looks like: it refused names Tally accepts, and `X - Y` is a common ledger convention. A separate master_identity_key follows §3.3b and stops where Tally stops; `AND` for `&`, a missing suffix word and a singular for a plural still refuse. It is separate from comparison_key rather than a widening of it, because that one is shared with voucher numbers and voucher-type names and §3.3b says nothing about those. Measured live: 16 of 16 hyphenated masters now bind from the spelling Tally itself accepts, where all 16 were near-misses before. Candidates becomes None | Listed | Truncated | Withheld. An empty vector was three different facts and a consumer reading is_empty() was wrong in two of them, a shape already got wrong twice by different lanes. Taken before merge because the contract has not shipped and this is the cheapest it will ever be; the consumer who pays for it measured thirty lines and reported the change improves its code. The MCP result gains an explicit listing discriminator, since a model is the caller that would read an empty array as "no such ledger exists"; the desktop DTO stays flat, where the screen already distinguishes the cases and is tested. Also: FallbackBinding says reallocate with a Journal and never Alter or Cancel, which §9.7 measured as duplicating with the target untouched while reporting success; the ADR records that identifier coverage is bimodal by client (42%, 0%, 0%, 0%) so the rule is a first-pass check and never a primary key; and BindingStatus says what a Bound does not establish — not that the master still exists, not that the requested name may be written, not that it is right in business terms, and no authority at all. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 66 +++++-- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 8 +- .../bridge-tally-core/src/master_binding.rs | 171 +++++++++++++++--- .../src/master_binding_tests.rs | 132 ++++++++++++-- src-tauri/src/agent_import.rs | 21 ++- src-tauri/src/source_draft/catalog.rs | 9 +- 7 files changed, 342 insertions(+), 67 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index b9aff3a6..68cb87df 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -110,6 +110,17 @@ unrelated period-labelled masters fuse on their period. The exclusion can only make a bind less likely, never more, which is the safe direction for a rule whose failure mode is posting against the wrong party. +**Coverage is a property of the client's naming habit, not of the problem.** +Measured across four catalogues: a retail motorcycle dealership carries an +embedded identifier in 91 of 214 ledgers (42%), because it literally names +customers that way; a B2B minerals trader, 0 of 105; a third catalogue, 0 of +470; and Bridge's own synthetic books, 0 of 470 until ten were seeded to give +the rule any live coverage at all. So this rule is a **first-pass check that is +decisive when it fires and absent more often than not** — it resolved a customer +three fuzzy name matches got wrong, and it can never be the primary key. The +binder must work with it absent, and does: name matching is not a fallback here +but the ordinary path. + An identifier binds only when it is **unique on both sides**: exactly one master in the catalog carries it, and the entity's identifiers select exactly one master overall. Any conflict is `Ambiguous`, never a bind. This keeps the @@ -125,9 +136,27 @@ favour. ### 3. Name matching binds only on an exact or normalized-exact unique hit `Exact` is byte equality with the observed master name. `Normalized` is equality -under a comparison key that applies NFC, folds Unicode dash and quote variants -to ASCII, lowercases, and collapses whitespace — and only when exactly one -master shares that key. Nothing else binds. There is no edit distance, no +under **Tally's own rule for when two master names are the same**, and only when +exactly one master shares it. + +That rule is measured, not chosen: `IMPLEMENTATION_GUIDE.md` §3.3b found Tally's +master-name matching to be case-insensitive **and separator-insensitive — a +hyphen matches a space** — and otherwise exact on letters. `AND` for `&`, a +missing suffix word, and a singular for a plural were all rejected. So the fold +lowercases, collapses whitespace, folds Unicode dash and quote variants to +ASCII, and treats `-` as a space; and it stops exactly where Tally stops. + +**Being stricter than the authority is not the safe direction it appears to +be.** It refuses names Tally would accept, and `X - Y` is a common ledger +convention — six of the seventeen hyphenated names in the observed books take +that shape. A binder that reports a near-miss for a name the book would have +matched has invented work, not prevented an error. + +This fold is deliberately **separate from the general comparison key**, which is +shared with other contracts for voucher numbers and voucher-type names. §3.3b +says nothing about those, and widening the shared fold to serve masters would be +the "never to make one caller's case pass" this ADR warns against. One fold per +notion of sameness, each named for the question it answers. Nothing else binds. There is no edit distance, no phonetic key, no token stemming, and no similarity threshold anywhere in the implementation. @@ -183,14 +212,29 @@ neighbouring value reads as an answer. The vocabulary is deliberately explicit so that "nothing survived to be shown" and "nothing exists" cannot be confused by reading one field. -**Why this is a doc and not a type.** Making the four cases unrepresentable — -an enum of `Listed` / `Truncated` / `Withheld` / `None` rather than a vector -plus two flags — would be the stronger fix and is the one P2 asks for. It is -deliberately deferred: the change is breaking, a stacked consumer already -depends on `candidates_truncated` as a predicate and holds the boundary with -tests, and forcing that rework while this contract is under review trades a -real improvement for a real regression risk. It should be revisited once this -and its dependent have merged. +**This is now a type as well as a doc.** `Candidates` is +`None | Listed | Truncated { found } | Withheld { found }`, so a consumer +matching it exhaustively is made to decide each case, and the wrong reading does +not compile rather than failing a test someone remembered to write. `listed()`, +`found()` and `is_incomplete()` cover the callers that do not need to match. +`is_incomplete()` is the predicate that matters: **true means the absence of a +listing is not the absence of a master**, and no consumer may report "nothing +like this is present" over it. + +It was taken before merge deliberately. The contract had not shipped, so this is +the cheapest the change would ever be; afterwards it would be a breaking change +to a published contract with three consumers behind it. The consumer who paid +for it measured its own cost at about thirty lines and reported that the change +made its code better rather than merely compatible — a hand-assembled +disjunction became an exhaustive match. + +**The fix stops at the crate boundary, and says so.** The MCP result carries an +explicit `listing` discriminator, because a model is precisely the caller that +would read an empty array as "no such ledger exists". The desktop DTO stays +flat: its screen already distinguishes the three cases and is tested on each, so +flattening there is a projection with a tested consumer rather than an +ambiguity. Neither boundary has the compiler behind it — this protects Rust +consumers, and the projections are the two places where that protection ends. ### 5. Status vocabulary diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index aaada3ce..62755309 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "1785bffb059d601272542b55b55ffc9b47fc46798860182fd21704283493597b", + "compatibility_surface_sha256": "0a9ab95b72635f32cb9dee780865f34c08aad7eb8b7b2d98f2ce7a8dea43e953", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 18204a3b..4496dc48 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "2b45787586520e9bbb33bc2bcbb1384229fde5919e9d3cbca901f44de241080b" + "sha256": "0be68cef1ae12231c218c20ec061eeb141d646698009ef9168ba90d3872bbb2a" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -331,7 +331,7 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "f531cd0c3bc2a5da6b6dc53271c473bf0b1e1ef776966692a4579651989a67da" + "sha256": "606874ff57fc21b8d94c21b3dfe86f515b476a8b3508615b2d18fea1dc4bb814" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -579,7 +579,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "2ab349ed9b64ecea7442d604fc8e75c7824daf3cd5945e5c199c68c7097141ed" + "sha256": "d73dd36572f8e27aa8fa34ad7c4a2ae20276df14746f398e83236bde624e16dc" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "1785bffb059d601272542b55b55ffc9b47fc46798860182fd21704283493597b" + "manifest_sha256": "0a9ab95b72635f32cb9dee780865f34c08aad7eb8b7b2d98f2ce7a8dea43e953" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 8f24848f..43bca31f 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -246,6 +246,59 @@ pub enum BindingBasis { NormalizedName, } +/// The masters worth showing, and — in the variant itself — what an absence of +/// them means. +/// +/// Replaces a `Vec` plus two flags, where empty was three different facts and a +/// consumer reading `is_empty()` was wrong in two of them. That shape had +/// already been got wrong twice by different lanes; here the compiler makes +/// each case an explicit decision instead. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", tag = "listing")] +pub enum Candidates { + /// Nothing resembles this name. An absence of masters, not of information. + None, + /// Every master found, listed. + Listed(Vec), + /// More were found than could be listed — the per-entity cap, or the + /// report's aggregate byte budget. + Truncated { + listed: Vec, + found: usize, + }, + /// A family this name reaches and separates none of: counted, and + /// deliberately not listed, because an arbitrary slice of it put the right + /// master out of view about a third of the time against live books. + Withheld { found: usize }, +} + +impl Candidates { + /// The masters actually listed. Empty for `None` and `Withheld` alike, so + /// never decide anything from this alone. + pub fn listed(&self) -> &[Candidate] { + match self { + Self::None | Self::Withheld { .. } => &[], + Self::Listed(listed) | Self::Truncated { listed, .. } => listed, + } + } + + /// Masters found before any truncation or withholding. + pub fn found(&self) -> usize { + match self { + Self::None => 0, + Self::Listed(listed) => listed.len(), + Self::Truncated { found, .. } | Self::Withheld { found } => *found, + } + } + + /// Whether masters exist that are not in `listed()`. The predicate a + /// consumer needs before it may report "nothing like this is present": + /// true here means the absence of a listing is not the absence of a master. + pub fn is_incomplete(&self) -> bool { + matches!(self, Self::Truncated { .. } | Self::Withheld { .. }) + } +} + /// What could not be bound, and why. This is the operator's work item, not an /// error path. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -263,23 +316,27 @@ pub struct Unresolved { /// A parked amount whose identity went into a write-only field is /// unreallocatable, and nothing about the write would say so. pub unresolved_identity: Vec, - /// The masters worth showing, most defensible first. - /// - /// **Empty is three different facts.** With `NoCandidate` it means nothing - /// resembles this name; with `NoDiscriminatingCandidate` it means - /// `candidate_count` masters resemble it and none is separable; with - /// `candidates_truncated` it means the list was cut, by the per-entity cap - /// or the report's aggregate byte budget. Reading the empty vector as - /// "nothing exists" is wrong in two of the three. Disambiguate on `reason` - /// and `candidates_truncated` — see ADR 0016 §4a. - pub candidates: Vec, - /// Masters found before any truncation, including a family that was - /// counted and deliberately not listed. - pub candidate_count: usize, - pub candidates_truncated: bool, + pub candidates: Candidates, } /// Exactly one outcome per source entity. +/// +/// **What a `Bound` does not establish**, written here because a computed check +/// gets read for more than it covers, and the caller cannot see the gap from +/// the value alone: +/// +/// - **Not that the master still exists.** The catalog is a snapshot. A caller +/// acting on a binding re-reads and revalidates through the admission path +/// that owns identity; nothing here is a lease on the book. +/// - **Not that the name may be written as given.** Only `ExactName` is byte +/// equality. A `NormalizedName` or `Identifier` bind means the payload and +/// the live name *differ*, and Bridge's write gate admits `exact` only — use +/// `catalog_name`, not what was requested. +/// - **Not that this is the right master in business terms.** It establishes +/// that one deterministic rule selected one master uniquely. Whether that +/// party is the one the document meant is a judgement the rules cannot make. +/// - **Not any authority.** A binding is a proposal: it approves nothing, +/// creates nothing, and dispatches nothing. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case", tag = "status")] pub enum BindingStatus { @@ -430,6 +487,20 @@ impl BindingReport { /// /// Constructed only from an entity that did not bind, so rebinding something /// that already matched is not a representable state. +/// +/// **Reallocate with a Journal moving the amount off the fallback ledger. +/// Never with `Alter`, and never with `Cancel`.** `TALLY_PROTOCOL_REFERENCE.md` +/// §9.7 measured voucher `Alter` returning `CREATED=1, ALTERED=0` and creating +/// a **duplicate with the target untouched** — four keys tested, all four +/// duplicating — and §9.6 the same for `Cancel`. The counters report success +/// either way, so the obvious correction produces exactly the double-posting a +/// parked entry exists to avoid, and says it worked. +/// +/// Re-import under the same client `REMOTEID` (§3.3a) is a real correction +/// path, but reaches only vouchers Bridge itself wrote; a hand-keyed voucher +/// has no client key. This is why the retained identity travels in the +/// narration: the Journal that reallocates it is written by a human or a later +/// batch, and the narration is what either can still read. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct FallbackBinding { class: MasterClass, @@ -526,7 +597,7 @@ impl SourceEntity { } Ok(Self { position, - key: comparison_key(&name), + key: master_identity_key(&name), name, identifiers, }) @@ -589,7 +660,7 @@ impl MasterCatalog { return Err(MasterBindingError::CatalogDuplicateName); } by_name.insert(name.clone(), entries.len()); - let key = comparison_key(&name); + let key = master_identity_key(&name); entries.push(CatalogEntry { identifiers: extract_identifiers(&name)?, tokens: tokens_of(&key), @@ -822,23 +893,36 @@ fn unresolved_from( .cmp(&right.1.rank()) .then_with(|| left.0.cmp(&right.0)) }); - // A suppressed family is still counted. The operator is told how many - // masters the name reaches even when none of them is worth listing. - let candidate_count = ordered.len().max(masters_found); - let listed = ordered.len().min(MAX_CANDIDATES_PER_ENTITY); - let candidates = ordered - .into_iter() - .take(MAX_CANDIDATES_PER_ENTITY) - .map_while(|(catalog_name, rule)| { - *budget = budget.checked_sub(catalog_name.len())?; - Some(Candidate { catalog_name, rule }) - }) - .collect::>(); + // The variant is derived here, in one place, from the same facts that chose + // the reason — so "empty" can never mean something the variant does not say. + let candidates = if ordered.is_empty() { + if masters_found > 0 { + Candidates::Withheld { + found: masters_found, + } + } else { + Candidates::None + } + } else { + let capped = ordered.len().min(MAX_CANDIDATES_PER_ENTITY); + let listed = ordered + .into_iter() + .take(MAX_CANDIDATES_PER_ENTITY) + .map_while(|(catalog_name, rule)| { + *budget = budget.checked_sub(catalog_name.len())?; + Some(Candidate { catalog_name, rule }) + }) + .collect::>(); + let found = masters_found.max(capped); + if listed.len() < found { + Candidates::Truncated { listed, found } + } else { + Candidates::Listed(listed) + } + }; let unresolved = Unresolved { reason, unresolved_identity: entity.identifiers.clone(), - candidate_count, - candidates_truncated: candidate_count > listed || candidates.len() < listed, candidates, }; if matches!(reason, UnboundReason::NoCandidate) { @@ -998,6 +1082,33 @@ pub(crate) fn comparison_key(value: &str) -> String { .join(" ") } +/// Whether Tally itself would consider two master names the same. +/// +/// This is not `comparison_key`, and the difference is not cosmetic. +/// `IMPLEMENTATION_GUIDE.md` §3.3b measured Tally's own master-name matching: +/// case-insensitive **and separator-insensitive — a hyphen matches a space** — +/// and otherwise exact on letters. `BRIDGE PROBE LEDGER A` matched a live +/// `BRIDGE-PROBE-LEDGER-A`; `AND` for `&`, a missing suffix word and a singular +/// for a plural were all rejected. +/// +/// Tally is the authority on what counts as the same master, so this fold +/// follows it. Being *stricter* than the authority is not the safe direction it +/// looks like: it refuses names Tally would accept, and `X - Y` is a common +/// ledger convention — six of seventeen hyphenated names in the observed books +/// take that shape. +/// +/// It is a **separate** function rather than a widening of `comparison_key` +/// precisely because that one is shared: voucher numbers and voucher-type names +/// fold through it too, and §3.3b says nothing about those. One fold per notion +/// of sameness, each named for the question it answers. +fn master_identity_key(value: &str) -> String { + comparison_key(value) + .replace('-', " ") + .split_whitespace() + .collect::>() + .join(" ") +} + fn tokens_of(key: &str) -> BTreeSet { key.split(|character: char| !character.is_alphanumeric()) .filter(|token| token.chars().count() >= MIN_TOKEN_CHARS) diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 68e6b842..938f4e2b 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -31,6 +31,7 @@ fn candidate_names(binding: &EntityBinding) -> Vec<&str> { .unresolved() .expect("binding did not resolve") .candidates + .listed() .iter() .map(|candidate| candidate.catalog_name.as_str()) .collect() @@ -255,6 +256,7 @@ fn near_duplicate_masters_produce_candidates_and_choose_none() { assert_eq!( unresolved .candidates + .listed() .iter() .map(|candidate| candidate.rule) .collect::>(), @@ -264,8 +266,8 @@ fn near_duplicate_masters_produce_candidates_and_choose_none() { CandidateRule::SharedToken ] ); - assert_eq!(unresolved.candidate_count, 3); - assert!(!unresolved.candidates_truncated); + assert_eq!(unresolved.candidates.found(), 3); + assert!(!unresolved.candidates.is_incomplete()); } #[test] @@ -288,6 +290,7 @@ fn a_truncated_source_name_surfaces_the_longer_master() { .unresolved() .expect("unbound") .candidates + .listed() .first() .map(|candidate| candidate.rule), Some(CandidateRule::CatalogPrefix) @@ -301,6 +304,7 @@ fn a_source_name_extending_a_master_surfaces_the_shorter_master() { let unresolved = binding.unresolved().expect("unbound"); assert!(unresolved .candidates + .listed() .iter() .any(|candidate| candidate.rule == CandidateRule::SourcePrefix && candidate.catalog_name == "DELTA WHOLESALE")); @@ -410,6 +414,47 @@ fn a_decisive_identifier_pointing_elsewhere_still_outranks_a_byte_exact_name() { ); } +#[test] +fn a_hyphen_matches_a_space_because_tally_says_so() { + // IMPLEMENTATION_GUIDE.md §3.3b, measured: Tally's own master-name matching + // treats a hyphen as a space. Being stricter than the authority refuses + // names Tally would accept, and `X - Y` is a common ledger convention. + let catalog = ledgers(&["Bank - HDFC Current", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Bank HDFC Current"); + assert_eq!( + binding.status, + BindingStatus::Bound { + catalog_name: "Bank - HDFC Current".to_string(), + basis: BindingBasis::NormalizedName, + } + ); + // And the reverse direction. + let hyphenated = ledgers(&["BRIDGE PROBE LEDGER A", "Beta Supply"]); + assert_eq!( + bind_one_name(&hyphenated, "BRIDGE-PROBE-LEDGER-A").bound_name(), + Some("BRIDGE PROBE LEDGER A") + ); +} + +#[test] +fn the_master_fold_stops_where_tally_stops() { + // §3.3b also measured what Tally does NOT normalise: `AND` for `&`, a + // missing suffix word, and a singular for a plural were all rejected. + // Folding further than the authority would bind names Tally refuses. + let catalog = ledgers(&["ZZ Ram & Sons Pvt Ltd", "Beta Supply"]); + for wrong in [ + "ZZ Ram AND Sons Pvt Ltd", + "ZZ Ram & Sons", + "ZZ Ram & Son Pvt Ltd", + ] { + assert_eq!( + bind_one_name(&catalog, wrong).bound_name(), + None, + "{wrong:?} bound, but Tally rejects it" + ); + } +} + #[test] fn a_trailing_space_never_claims_byte_equality() { // `Bank ` against live `Bank` must not report exact: the import file would @@ -497,6 +542,7 @@ fn a_report_bounds_its_own_candidate_allocation() { .map(|unresolved| { unresolved .candidates + .listed() .iter() .map(|candidate| candidate.catalog_name.len()) .sum::() @@ -509,12 +555,12 @@ fn a_report_bounds_its_own_candidate_allocation() { let starved = report .unbound() .filter_map(|entity| entity.unresolved()) - .filter(|unresolved| unresolved.candidates.is_empty()) + .filter(|unresolved| unresolved.candidates.listed().is_empty()) .collect::>(); assert!(!starved.is_empty(), "the budget must actually bite here"); - assert!(starved - .iter() - .all(|unresolved| unresolved.candidate_count > 0 && unresolved.candidates_truncated)); + assert!(starved.iter().all( + |unresolved| unresolved.candidates.found() > 0 && unresolved.candidates.is_incomplete() + )); } #[test] @@ -547,6 +593,59 @@ fn more_identifiers_than_the_bound_is_refused_not_truncated() { ); } +#[test] +fn the_listing_variant_says_what_an_absent_candidate_means() { + // The three facts that used to share one empty vector, now told apart by + // the type. A consumer matching exhaustively is made to decide each. + let catalog = ledgers(&["Alpha Traders", "Beta Supply"]); + assert_eq!( + bind_one_name(&catalog, "Zeta Placeholder") + .unresolved() + .expect("unbound") + .candidates, + Candidates::None, + "nothing resembles it" + ); + + let family = (0..MAX_PREFIX_FAMILY + 5) + .map(|index| format!("ALPHAGROUP UNIT {index:02}")) + .collect::>(); + let family = MasterCatalog::new(MasterClass::Ledger, &family).expect("valid"); + assert_eq!( + bind_one_name(&family, "ALPHAGROUP") + .unresolved() + .expect("unbound") + .candidates, + Candidates::Withheld { + found: MAX_PREFIX_FAMILY + 5 + }, + "many exist and none separates them" + ); + + let listed = ledgers(&["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA", "Beta Supply"]); + let binding = bind_one_name(&listed, "ALPHA"); + let candidates = &binding.unresolved().expect("unbound").candidates; + assert!(matches!(candidates, Candidates::Listed(_))); + assert_eq!(candidates.found(), 3); +} + +#[test] +fn only_an_incomplete_listing_may_withhold_an_absence() { + // The predicate a consumer needs before reporting "nothing like this is + // present". `None` permits that conclusion; the other two forbid it. + assert!(!Candidates::None.is_incomplete()); + assert!(!Candidates::Listed(Vec::new()).is_incomplete()); + assert!(Candidates::Withheld { found: 30 }.is_incomplete()); + assert!(Candidates::Truncated { + listed: Vec::new(), + found: 9 + } + .is_incomplete()); + // `found` is the total, never the listed length, wherever it is known. + assert_eq!(Candidates::Withheld { found: 30 }.found(), 30); + assert!(Candidates::Withheld { found: 30 }.listed().is_empty()); +} + // --- candidate discipline -------------------------------------------------- #[test] @@ -558,7 +657,7 @@ fn a_catalog_wide_token_stops_discriminating() { let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); // "placeholder" is carried by every entry, so it may not pull all 41 in. let binding = bind_one_name(&catalog, "PLACEHOLDER ZETA"); - assert!(binding.unresolved().expect("unbound").candidate_count <= 1); + assert!(binding.unresolved().expect("unbound").candidates.found() <= 1); } #[test] @@ -575,9 +674,9 @@ fn a_prefix_matching_a_whole_family_is_counted_and_deliberately_not_listed() { let binding = bind_one_name(&catalog, "ALPHAGROUP"); let unresolved = binding.unresolved().expect("unbound"); assert_eq!(reason(&binding), UnboundReason::NoDiscriminatingCandidate); - assert!(unresolved.candidates.is_empty()); - assert_eq!(unresolved.candidate_count, MAX_PREFIX_FAMILY + 5); - assert!(unresolved.candidates_truncated); + assert!(unresolved.candidates.listed().is_empty()); + assert_eq!(unresolved.candidates.found(), MAX_PREFIX_FAMILY + 5); + assert!(unresolved.candidates.is_incomplete()); } #[test] @@ -589,8 +688,8 @@ fn a_family_within_the_bound_is_still_listed_in_full() { let binding = bind_one_name(&catalog, "ALPHAGROUP"); let unresolved = binding.unresolved().expect("unbound"); assert_eq!(reason(&binding), UnboundReason::NearMiss); - assert_eq!(unresolved.candidates.len(), MAX_PREFIX_FAMILY); - assert!(!unresolved.candidates_truncated); + assert_eq!(unresolved.candidates.listed().len(), MAX_PREFIX_FAMILY); + assert!(!unresolved.candidates.is_incomplete()); } #[test] @@ -609,8 +708,8 @@ fn the_reported_count_is_the_union_of_suppressed_and_listed_candidates() { let unresolved = binding.unresolved().expect("unbound"); // The shorter master is still listed; the family behind it is not. assert_eq!(candidate_names(&binding), ["Alpha"]); - assert_eq!(unresolved.candidate_count, MAX_PREFIX_FAMILY + 6); - assert!(unresolved.candidates_truncated); + assert_eq!(unresolved.candidates.found(), MAX_PREFIX_FAMILY + 6); + assert!(unresolved.candidates.is_incomplete()); } #[test] @@ -637,6 +736,7 @@ fn candidate_order_is_rule_then_name_and_never_a_ranking() { assert_eq!( unresolved .candidates + .listed() .iter() .map(|candidate| (candidate.catalog_name.as_str(), candidate.rule)) .collect::>(), @@ -1005,9 +1105,9 @@ fn a_firm_wide_word_does_not_drag_the_whole_book_into_every_candidate_list() { let binding = bind_one_name(&catalog, "OMEGA PLACEHOLDER"); let unresolved = binding.unresolved().expect("unbound"); assert!( - unresolved.candidate_count <= MAX_CANDIDATES_PER_ENTITY, + unresolved.candidates.found() <= MAX_CANDIDATES_PER_ENTITY, "a firm-wide word pulled in {} candidates", - unresolved.candidate_count + unresolved.candidates.found() ); } diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index e560b5c2..2589d3b4 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -9,7 +9,8 @@ use crate::tally::standard_ledger_catalog::{ render_standard_ledger_catalog_request, }; use bridge_tally_core::master_binding::{ - self, BindingBasis, BindingStatus, EntityBinding, MasterCatalog, MasterClass, SourceEntity, + self, BindingBasis, BindingStatus, Candidates, EntityBinding, MasterCatalog, MasterClass, + SourceEntity, }; use bridge_tally_core::ExactDecimal; use bridge_tally_protocol::outstandings_shared::DateBoundaryProfile; @@ -1188,6 +1189,7 @@ fn master_match_json(binding: &EntityBinding) -> Value { let mut bytes = 0_usize; let candidates = unresolved .candidates + .listed() .iter() .take_while(|candidate| { bytes = bytes.saturating_add(candidate.catalog_name.len()); @@ -1200,6 +1202,18 @@ fn master_match_json(binding: &EntityBinding) -> Value { }) }) .collect::>(); + // The listing state is carried explicitly rather than left to be + // inferred from an empty array. A model is exactly the caller that + // would read "no candidates" as "no such ledger exists", and for + // `withheld` that is false: masters were found and deliberately not + // listed because none of them separates the requested name. + let found = unresolved.candidates.found(); + let listing = match unresolved.candidates { + Candidates::None => "none", + Candidates::Withheld { .. } => "withheld", + _ if candidates.len() < found => "truncated", + _ => "listed", + }; // No `exact_live_spelling`. Naming one candidate as the live // spelling is the auto-resolution that rejected a batch once. json!({ @@ -1209,8 +1223,9 @@ fn master_match_json(binding: &EntityBinding) -> Value { _ => "near_miss", }, "reason": unresolved.reason.safe_reason_code(), - "candidate_count": unresolved.candidate_count, - "candidates_truncated": candidates.len() < unresolved.candidate_count, + "listing": listing, + "candidate_count": found, + "candidates_truncated": listing != "listed" && listing != "none", "candidates": candidates, "unresolved_identity": unresolved .unresolved_identity diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index dd3cae80..de5979ec 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -189,13 +189,18 @@ fn source_entry_bindings( bound_target: None, bound_basis: None, unbound_reason: Some(unresolved.reason.safe_reason_code()), - candidates_truncated: unresolved.candidates_truncated, + // The screen distinguishes the three cases from + // `candidate_count` against an empty list and is tested + // on each, so the DTO stays flat and this projection is + // the only place the typed shape is flattened. + candidates_truncated: unresolved.candidates.is_incomplete(), candidates: unresolved .candidates + .listed() .iter() .map(|candidate| candidate.catalog_name.clone()) .collect(), - candidate_count: unresolved.candidate_count, + candidate_count: unresolved.candidates.found(), } } }, From 168e094e147972e7442b4c7c0f54b653c387c099 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 02:57:18 +0530 Subject: [PATCH 15/75] Close the six findings the contract change generated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three were the same shape recurring: an identifier built from something that identifies a period rather than a party. A token carrying letters now never yields a standalone numeric, whether or not it qualified as a code — `Part A12345678` was reaching an unrelated `Bank 12345678` through the one-letter gap the code test rejects. Period labels are recognised by their numbers rather than their words, which catches `SEPTEMBER2025` and `2025QUARTER1` that no cap on the alphabetic run ever would: a month name can be any length, a year cannot. And a fiscal range is excluded before its digits are fused, since `2025-2026` strips to an eight-digit run no calendar reading rejects. Fallback assignment now checks catalog provenance, not just class: two ledger catalogs are both Ledger, and a fallback drawn from the one the report never saw names a master that was never a candidate. Candidate collection selects by index and clones only what it retains, instead of cloning every match before the cap and the budget discard most of it. ADR 0016 quoted thresholds this module stopped using two rounds ago, and it is the contract two surfaces integrate against. Synced — and a test now reads the ADR and asserts it quotes the live constants, so the next drift fails rather than waiting to be noticed. Verified against a positive control: changing a constant without the document fails it. It also caught a false positive of its own on first run, which was the detector being too strict about `(10%)` rather than the ADR being wrong. Live re-measure over 485 names, 2,330 cases: 434 of 434 listed rows still contain the right master, median listed length 2, no wrong binds. Identifier binds 11 -> 8 with bound and unbound totals unchanged: three mutations that had bound to themselves through a leaked numeric now bind by name instead. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 31 +++- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 174 ++++++++++++------ .../src/master_binding_tests.rs | 97 +++++++++- 5 files changed, 246 insertions(+), 62 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 68cb87df..f8e553e4 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -98,11 +98,32 @@ reference, say): a rate, a house number and a masked last-four cannot qualify — a last-four written as digits falls through to near-miss rather than binding two accounts that share four digits. -- **Code** — a token holding at least one letter and at least - `MIN_CODE_IDENTIFIER_DIGITS` (2) digits, of at least - `MIN_CODE_IDENTIFIER_CHARS` (4) alphanumeric characters. Canonical form is - uppercase alphanumerics, so a punctuated part number and an unpunctuated one - agree. +- **Code** — a token holding at least two letters and at least + `MIN_CODE_IDENTIFIER_DIGITS` (3) digits, of at least + `MIN_CODE_IDENTIFIER_CHARS` (8) alphanumeric characters, and not a period + label. Canonical form is uppercase alphanumerics, so a punctuated part number + and an unpunctuated one agree. + + These thresholds were raised twice under review, from 4/2/1. Enumerating the + period spellings that must not become identifiers — `FY25`, then `APR2025`, + then `SEPTEMBER2025` — kept losing to the next spelling, so length carries + what a list of prefixes could not: a registration code clears eight + alphanumerics with three digits, and a period label does not. **Measured + against 485 live ledger names, exactly one yields a code identifier at all**, + so the cost of the strictness is nothing observed. + +**A token carrying letters never yields a standalone numeric**, whether or not +it qualified as a code. Otherwise `Part A12345678` reaches an unrelated +`Bank 12345678` through the one-letter gap the code test rejects: a token +identifies by its whole shape or not at all. + +**Period labels are recognized by their numbers, not their words.** A token is +a period when every number in it reads as a year or a small ordinal — which +catches `SEPTEMBER2025` and `2025QUARTER1` that no cap on the alphabetic run +ever would, because a month name can be any length and a year cannot. A fiscal +range (`2025-2026`, `2025/2026`) is excluded before its digits are fused, since +stripping the separator produced an eight-digit run that no calendar reading +rejects. One narrow exclusion applies to the numeric shape: an eight-digit run that reads as a calendar date in 1900–2199 is a date, not an identifier. Without it two diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 62755309..33b79ada 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "0a9ab95b72635f32cb9dee780865f34c08aad7eb8b7b2d98f2ce7a8dea43e953", + "compatibility_surface_sha256": "88ff6fd9b10c884a2b19c4241f0912e06e1f516c291e9ee5e30e89aec9e756a4", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 4496dc48..77d7ec88 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "0be68cef1ae12231c218c20ec061eeb141d646698009ef9168ba90d3872bbb2a" + "sha256": "5a61b51c2ef36b63693a3114a5779acfb5ce8dbd9e6e504a2a9b15ad31aac5b8" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "0a9ab95b72635f32cb9dee780865f34c08aad7eb8b7b2d98f2ce7a8dea43e953" + "manifest_sha256": "88ff6fd9b10c884a2b19c4241f0912e06e1f516c291e9ee5e30e89aec9e756a4" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 43bca31f..f923096f 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -117,7 +117,8 @@ pub enum MasterBindingError { TooManyIdentifiers, #[error("fallback master was not a current catalog entry")] FallbackNotInCatalog, - /// A catalog of the wrong class, or an entity from another report. + /// A catalog of the wrong class, a catalog the report was not produced + /// from, or an entity from another report. #[error("catalog did not match the report it is used with")] ClassMismatch, } @@ -393,6 +394,7 @@ pub struct BindingTotals { #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct BindingReport { class: MasterClass, + catalog: CatalogFingerprint, entities: Vec, } @@ -401,6 +403,11 @@ impl BindingReport { self.class } + /// The catalog this report was produced from. + pub fn catalog(&self) -> CatalogFingerprint { + self.catalog + } + pub fn entities(&self) -> &[EntityBinding] { &self.entities } @@ -434,7 +441,10 @@ impl BindingReport { catalog: &MasterCatalog, fallback_name: &str, ) -> Result { - if catalog.class != self.class { + // Class alone is not provenance: two ledger catalogs are both + // `Ledger`, and a fallback drawn from the one the report never saw + // would name a master that was never a candidate for this entity. + if catalog.class != self.class || catalog.fingerprint != self.catalog { return Err(MasterBindingError::ClassMismatch); } let entity = self @@ -628,9 +638,18 @@ struct CatalogEntry { /// /// Valid by construction: `bind` cannot fail because everything that could fail /// was decided here. +/// Which catalog a report was produced from. +/// +/// Not a security property and not a Tally identity — it distinguishes two +/// catalogs of the same class held in one process, which is the state that let +/// a fallback be drawn from a book the report never saw. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub struct CatalogFingerprint(u64); + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MasterCatalog { class: MasterClass, + fingerprint: CatalogFingerprint, entries: Vec, by_name: BTreeMap, by_key: BTreeMap>, @@ -703,8 +722,21 @@ impl MasterCatalog { BTreeSet::new() }; + // Order-independent, so the same masters read twice fingerprint alike + // however the book returned them. + let fingerprint = + CatalogFingerprint(entries.iter().fold(class as u64 + 1, |accumulated, entry| { + accumulated + ^ entry + .name + .bytes() + .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x1000_0000_01b3) + }) + })); Ok(Self { class, + fingerprint, entries, by_name, by_key, @@ -718,6 +750,12 @@ impl MasterCatalog { self.class } + /// Which catalog this is, for a caller that must prove a later value came + /// from the same one. + pub fn fingerprint(&self) -> CatalogFingerprint { + self.fingerprint + } + /// Masters in this catalog. Never zero: an empty catalog is refused at /// construction, so there is no emptiness for a caller to test. pub fn master_count(&self) -> usize { @@ -752,6 +790,7 @@ pub fn bind( let mut budget = MAX_REPORT_CANDIDATE_BYTES; Ok(BindingReport { class: catalog.class, + catalog: catalog.fingerprint, entities: entities .iter() .map(|entity| bind_one(catalog, entity, &mut budget)) @@ -849,7 +888,7 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) } else { UnboundReason::NoCandidate }; - unresolved_from(entity, reason, candidates, masters_found, budget) + unresolved_from(catalog, entity, reason, candidates, masters_found, budget) } } }; @@ -871,27 +910,28 @@ fn unresolved_status( ) -> BindingStatus { let (mut candidates, masters_found) = collect_candidates(catalog, entity, identifier_matches); if let Some(index) = exact { - let name = catalog.entries[index].name.as_str(); - if !candidates.iter().any(|(candidate, _)| candidate == name) { - candidates.push((name.to_string(), CandidateRule::NormalizedEqual)); + if !candidates.iter().any(|(candidate, _)| *candidate == index) { + candidates.push((index, CandidateRule::NormalizedEqual)); } } - unresolved_from(entity, reason, candidates, masters_found, budget) + unresolved_from(catalog, entity, reason, candidates, masters_found, budget) } fn unresolved_from( + catalog: &MasterCatalog, entity: &SourceEntity, reason: UnboundReason, - candidates: Vec<(String, CandidateRule)>, + candidates: Vec<(usize, CandidateRule)>, masters_found: usize, budget: &mut usize, ) -> BindingStatus { let mut ordered = candidates; ordered.sort_by(|left, right| { - left.1 - .rank() - .cmp(&right.1.rank()) - .then_with(|| left.0.cmp(&right.0)) + left.1.rank().cmp(&right.1.rank()).then_with(|| { + catalog.entries[left.0] + .name + .cmp(&catalog.entries[right.0].name) + }) }); // The variant is derived here, in one place, from the same facts that chose // the reason — so "empty" can never mean something the variant does not say. @@ -908,9 +948,13 @@ fn unresolved_from( let listed = ordered .into_iter() .take(MAX_CANDIDATES_PER_ENTITY) - .map_while(|(catalog_name, rule)| { + .map_while(|(index, rule)| { + let catalog_name = &catalog.entries[index].name; *budget = budget.checked_sub(catalog_name.len())?; - Some(Candidate { catalog_name, rule }) + Some(Candidate { + catalog_name: catalog_name.clone(), + rule, + }) }) .collect::>(); let found = masters_found.max(capped); @@ -939,7 +983,7 @@ fn collect_candidates( catalog: &MasterCatalog, entity: &SourceEntity, identifier_matches: &BTreeSet, -) -> (Vec<(String, CandidateRule)>, usize) { +) -> (Vec<(usize, CandidateRule)>, usize) { let mut best: BTreeMap = BTreeMap::new(); let mut offer = |index: usize, rule: CandidateRule| { best.entry(index) @@ -1015,12 +1059,10 @@ fn collect_candidates( .chain(suppressed_family) .collect::>() .len(); - ( - best.into_iter() - .map(|(index, rule)| (catalog.entries[index].name.clone(), rule)) - .collect(), - found, - ) + // Indices, not names. Cloning every match before the cap and the budget + // discarded most of the work for an entity whose identifier is shared by + // many rows, and an admitted draft repeats that per entry. + (best.into_iter().collect(), found) } /// A name is retained **verbatim**, on both sides. @@ -1147,14 +1189,23 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro kind: IdentifierKind::Code, value: canonical, }); - // Its digits are part of this code, not an identifier of their own. + } + // Digits sitting beside letters belong to that token, whether or not it + // qualified as a code. Emitting them separately let `Part A12345678` + // reach an unrelated `Bank 12345678` through the one-letter gap that + // the code test rejects — a token either identifies by its whole shape + // or not at all. + if letters > 0 { continue; } for run in token.split(|character: char| { !(character.is_ascii_digit() || character == '-' || character == '/') }) { let digits = run.chars().filter(char::is_ascii_digit).collect::(); - if digits.len() >= MIN_NUMERIC_IDENTIFIER_DIGITS && !is_plausible_date(&digits) { + if digits.len() >= MIN_NUMERIC_IDENTIFIER_DIGITS + && !is_plausible_date(&digits) + && !is_year_range(run) + { identifiers.insert(Identifier { kind: IdentifierKind::Numeric, value: digits, @@ -1170,25 +1221,24 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro /// A period label identifies a period, not a party or an item. Two unrelated /// ledgers routinely share one — `Purchases FY2025` and `Sales FY2025`, -/// `Purchases APR2025` and `Sales APR2025` — and identifier-first matching -/// would bind the source to whichever exists before it compared the names. +/// `Purchases SEPTEMBER2025` and `Sales SEPTEMBER2025` — and identifier-first +/// matching would bind the source to whichever exists before comparing names. /// -/// Recognized by *shape* rather than by a vocabulary of prefixes, because a -/// list of prefixes kept missing one more spelling: every run in the token is -/// either a short alphabetic marker or a number that reads as a year or a -/// small ordinal, and there are at most three runs. `FY2025`, `APR2025`, -/// `2025Q1` and `Q3` all match; `PH01AB00` and `AB12345678` do not. +/// The test is on the **numbers**, not on the words: a token is a period label +/// when it carries at least one number and **every** number in it reads as a +/// year or a small ordinal. Capping the length of the alphabetic run was the +/// previous attempt and it kept losing to longer spellings — `APR2025` was +/// caught while `SEPTEMBER2025` and `2025QUARTER1` walked through. A month name +/// can be any length; a year cannot. /// -/// Like every exclusion here it can only make a bind *less* likely. +/// An identity-bearing code survives this because its digits do not read as +/// periods: `PH01AB00` carries `00`, `AB12345678` carries an eight-digit run, +/// and a registration number carries something no calendar would produce. Like +/// every exclusion here it can only make a bind *less* likely. fn is_period_label(canonical: &str) -> bool { - let mut runs = 0_usize; - let mut has_period_number = false; + let mut has_number = false; let mut rest = canonical; while !rest.is_empty() { - runs += 1; - if runs > 3 { - return false; - } let alphabetic = rest.starts_with(|character: char| character.is_ascii_alphabetic()); let split = rest .find(|character: char| character.is_ascii_alphabetic() != alphabetic) @@ -1196,26 +1246,44 @@ fn is_period_label(canonical: &str) -> bool { let (run, tail) = rest.split_at(split); rest = tail; if alphabetic { - if run.len() > 4 { - return false; - } - } else { - let value = run.parse::().unwrap_or(u32::MAX); - let reads_as_period = match run.len() { - 1 | 2 => (1..=99).contains(&value), - 4 => (1900..=2199).contains(&value), - _ => false, - }; - if !reads_as_period { - return false; - } - has_period_number = true; + continue; + } + if !reads_as_period_number(run) { + return false; } + has_number = true; + } + has_number +} + +/// A year, or a small ordinal such as a month or quarter. +fn reads_as_period_number(run: &str) -> bool { + let value = run.parse::().unwrap_or(u32::MAX); + match run.len() { + 1 | 2 => (1..=99).contains(&value), + 4 => (1900..=2199).contains(&value), + _ => false, + } +} + +/// `2025-2026` and `2025/2026` are fiscal years, which two unrelated ledgers +/// share as routinely as they share a month. Stripping the separator turned +/// them into an eight-digit run that no calendar-date reading rejects, so the +/// range has to be recognized before the digits are fused. +fn is_year_range(run: &str) -> bool { + let mut halves = run.split(['-', '/']); + match (halves.next(), halves.next(), halves.next()) { + (Some(first), Some(second), None) => [first, second].iter().all(|half| { + half.len() == 4 + && half + .parse::() + .is_ok_and(|year| (1900..=2199).contains(&year)) + }), + _ => false, } - has_period_number } -/// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project +/// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project /// admits is a date, not an identifier. Recognizing only `YYYYMMDD` left /// `01012026` binding a source to an unrelated master that shares its period /// label. Being generous here can only make a bind *less* likely, which is the diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 938f4e2b..7151ad1f 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -501,7 +501,21 @@ fn a_fiscal_period_label_is_not_an_identity_bearing_code() { // matching would otherwise bind the source to whichever one exists before // it ever compared the names. for label in [ - "FY25", "FY2025", "AY2026", "Q3", "H2", "PER2026", "APR2025", "2025Q1", "MAR26", "H12026", + "FY25", + "FY2025", + "AY2026", + "Q3", + "H2", + "PER2026", + "APR2025", + "2025Q1", + "MAR26", + "H12026", + // A month name can be any length; a year cannot. Capping the + // alphabetic run kept losing to longer spellings. + "SEPTEMBER2025", + "2025QUARTER1", + "DECEMBER2026", ] { assert!( entity(&format!("Purchases {label}")) @@ -563,6 +577,44 @@ fn a_report_bounds_its_own_candidate_allocation() { )); } +#[test] +fn a_token_carrying_letters_never_yields_a_standalone_number() { + // A one-letter token fails the code test, and its digits were then escaping + // as a numeric of their own — so `Part A12345678` could reach an unrelated + // `Bank 12345678`. A token identifies by its whole shape or not at all. + assert!(entity("Part A12345678").identifiers().is_empty()); + let catalog = ledgers(&["Bank 12345678", "Beta Supply"]); + assert_eq!(bind_one_name(&catalog, "Part A12345678").bound_name(), None); + // A bare digit run beside no letters is still an identifier. + assert_eq!(entity("Party (5550001001)").identifiers().len(), 1); +} + +#[test] +fn a_fiscal_year_range_is_a_period_not_an_account_number() { + // `2025-2026` strips to an eight-digit run that no calendar reading + // rejects, and two unrelated ledgers share a fiscal year as routinely as + // they share a month. + for range in ["2025-2026", "2025/2026", "1999-2000"] { + assert!( + entity(&format!("Purchases {range}")) + .identifiers() + .is_empty(), + "{range} was treated as an identifier" + ); + } + let catalog = ledgers(&["Sales 2025-2026", "Beta Supply"]); + assert_eq!( + bind_one_name(&catalog, "Purchases 2025-2026").bound_name(), + None + ); + // A punctuated account number that is not a year range still binds. + let accounts = ledgers(&["Party 5550001-002", "Beta Supply"]); + assert_eq!( + bind_one_name(&accounts, "Other 5550001002").bound_name(), + Some("Party 5550001-002") + ); +} + #[test] fn an_eight_digit_date_in_any_admitted_order_is_not_an_identifier() { for date in ["20260910", "01012026", "31122026", "12312026"] { @@ -854,12 +906,55 @@ fn a_fallback_cannot_be_drawn_from_another_catalog_class_or_another_report() { stock_report.assign_fallback(7, &stock, "Scrap Placeholder"), Err(MasterBindingError::ClassMismatch) ); + // Nor may a *same-class* catalog the report was never produced from supply + // the fallback: class is not provenance, and the master would never have + // been a candidate for this entity. + let other_ledgers = ledgers(&["Alpha Traders", "Different Suspense"]); + let ledger_report = bound(&ledger, &[entity("Zeta Placeholder")]); + assert_eq!( + ledger_report.assign_fallback(0, &other_ledgers, "Different Suspense"), + Err(MasterBindingError::ClassMismatch) + ); + assert_ne!(ledger.fingerprint(), other_ledgers.fingerprint()); + // The same masters read twice fingerprint alike, whatever order they came + // back in — a re-read must not invalidate a report. + let reordered = ledgers(&["Suspense Placeholder", "Alpha Traders", "Beta Supply"]); + let forward = ledgers(&["Alpha Traders", "Beta Supply", "Suspense Placeholder"]); + assert_eq!(reordered.fingerprint(), forward.fingerprint()); assert_eq!( MasterBindingError::ClassMismatch.safe_reason_code(), "master_class_mismatch" ); } +#[test] +fn the_adr_quotes_the_thresholds_this_module_actually_uses() { + // ADR 0016 is the contract two surfaces integrate against, so a threshold + // that moves in code and not in the document sends a future integration + // the wrong rule. "Remember to update the record" is the kind of rule this + // project prefers to replace with something that fails. + const ADR: &str = include_str!("../../../../docs/adr/0016-master-binding-authority.md"); + for (constant, value) in [ + ( + "MIN_NUMERIC_IDENTIFIER_DIGITS", + MIN_NUMERIC_IDENTIFIER_DIGITS, + ), + ("MIN_CODE_IDENTIFIER_DIGITS", MIN_CODE_IDENTIFIER_DIGITS), + ("MIN_CODE_IDENTIFIER_CHARS", MIN_CODE_IDENTIFIER_CHARS), + ("MAX_CANDIDATES_PER_ENTITY", MAX_CANDIDATES_PER_ENTITY), + ("COMMON_TOKEN_PERCENT", COMMON_TOKEN_PERCENT), + ] { + // A percentage reads naturally as `(10%)`; both spellings count, and + // neither lets a changed number pass. + let plain = format!("`{constant}` ({value})"); + let percent = format!("`{constant}` ({value}%)"); + assert!( + ADR.contains(&plain) || ADR.contains(&percent), + "ADR 0016 does not quote {constant} as {value}; it must read {plain:?}" + ); + } +} + // --- the vocabulary is stable ---------------------------------------------- #[test] From c7cccbcb3a92415d05e84852b87e3a07984794cc Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 03:07:07 +0530 Subject: [PATCH 16/75] Stop a weaker rule reinstating a withheld family, and make Listed serializable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reproduced by execution before fixing, and both re-verified against the same reproduction after. A token shared across a name family IS the family. Where the catalog is large enough that the token stays under the common-token threshold — 30 rows among 330 is 9% — the shared-token pass re-offered exactly the rows the prefix pass had withheld, restoring the arbitrary capped slice that withholding exists to prevent. Measured: the case reported near_miss with 25 of 30 listed and the intended master outside the slice; it now reports no_discriminating_candidate with the family counted and none listed. Weaker rules now skip the withheld set; decisive rules still reach a family member on its own evidence, because the identifier or the whole key is what separates that one from its siblings. Candidates::Listed was a newtype variant carrying a sequence, and under Serde's internally tagged representation a tag cannot be merged into a sequence — so serializing the MOST COMMON unresolved result failed at runtime while the other three variants succeeded. Now a struct variant. No test caught it because none had ever serialized an Unresolved, only a Bound; every variant now round-trips in a test. Live re-measure unchanged: 434 of 434 listed rows contain the right master, median listed length 2, no wrong binds. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 118 +++++++++++------- .../src/master_binding_tests.rs | 62 ++++++++- 4 files changed, 134 insertions(+), 52 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 33b79ada..5bee5cef 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "88ff6fd9b10c884a2b19c4241f0912e06e1f516c291e9ee5e30e89aec9e756a4", + "compatibility_surface_sha256": "36b93b23265867e8b69a3ff288721e1a783820d88ef49d146a7e2fd26f7b8969", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 77d7ec88..5a94d598 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "5a61b51c2ef36b63693a3114a5779acfb5ce8dbd9e6e504a2a9b15ad31aac5b8" + "sha256": "d0ef94689fd4f0d91649beb105e68aa46a64e1c6e73f116e3e8a7ea01bf18bc3" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "88ff6fd9b10c884a2b19c4241f0912e06e1f516c291e9ee5e30e89aec9e756a4" + "manifest_sha256": "36b93b23265867e8b69a3ff288721e1a783820d88ef49d146a7e2fd26f7b8969" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index f923096f..fe6d3c55 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -260,7 +260,12 @@ pub enum Candidates { /// Nothing resembles this name. An absence of masters, not of information. None, /// Every master found, listed. - Listed(Vec), + /// + /// A struct variant, not a newtype: under Serde's internally tagged + /// representation a tag cannot be merged into a sequence, and a newtype + /// here failed to serialize at runtime — on the most common unresolved + /// result, while the other three variants succeeded. + Listed { listed: Vec }, /// More were found than could be listed — the per-entity cap, or the /// report's aggregate byte budget. Truncated { @@ -279,7 +284,7 @@ impl Candidates { pub fn listed(&self) -> &[Candidate] { match self { Self::None | Self::Withheld { .. } => &[], - Self::Listed(listed) | Self::Truncated { listed, .. } => listed, + Self::Listed { listed } | Self::Truncated { listed, .. } => listed, } } @@ -287,7 +292,7 @@ impl Candidates { pub fn found(&self) -> usize { match self { Self::None => 0, - Self::Listed(listed) => listed.len(), + Self::Listed { listed } => listed.len(), Self::Truncated { found, .. } | Self::Withheld { found } => *found, } } @@ -961,7 +966,7 @@ fn unresolved_from( if listed.len() < found { Candidates::Truncated { listed, found } } else { - Candidates::Listed(listed) + Candidates::Listed { listed } } }; let unresolved = Unresolved { @@ -984,6 +989,28 @@ fn collect_candidates( entity: &SourceEntity, identifier_matches: &BTreeSet, ) -> (Vec<(usize, CandidateRule)>, usize) { + // Masters this name reaches by prefix. The key index is ordered, so this is + // a range walk rather than a scan of the catalog per entity. + let extending = if entity.key.chars().count() >= MIN_PREFIX_KEY_CHARS { + catalog + .by_key + .range(entity.key.clone()..) + .take_while(|(key, _)| key.starts_with(&entity.key)) + .filter(|(key, _)| *key != &entity.key) + .flat_map(|(_, holders)| holders.iter().copied()) + .collect::>() + } else { + BTreeSet::new() + }; + // Beyond the bound they are a family this name does not separate, and an + // arbitrary capped slice of one omitted the right master about a third of + // the time against live books. Counted, and withheld rather than listed. + let withheld = if extending.len() > MAX_PREFIX_FAMILY { + extending.clone() + } else { + BTreeSet::new() + }; + let mut best: BTreeMap = BTreeMap::new(); let mut offer = |index: usize, rule: CandidateRule| { best.entry(index) @@ -995,46 +1022,41 @@ fn collect_candidates( .or_insert(rule); }; + // A decisive rule reaches a master on its own evidence, so it still applies + // to a member of a withheld family: the identifier, or the whole key, is + // exactly what separates that one from its siblings. for index in identifier_matches { offer(*index, CandidateRule::SharedIdentifier); } - if let Some(holders) = catalog.by_key.get(&entity.key) { - for index in holders { - offer(*index, CandidateRule::NormalizedEqual); - } + for index in catalog.by_key.get(&entity.key).into_iter().flatten() { + offer(*index, CandidateRule::NormalizedEqual); } - let mut suppressed_family: BTreeSet = BTreeSet::new(); - if entity.key.chars().count() >= MIN_PREFIX_KEY_CHARS { - // The key index is ordered, so both prefix directions are range or - // point lookups rather than a scan of the whole catalog per entity. - let extending = catalog - .by_key - .range(entity.key.clone()..) - .take_while(|(key, _)| key.starts_with(&entity.key)) - .filter(|(key, _)| *key != &entity.key) - .flat_map(|(_, holders)| holders.iter().copied()) - .collect::>(); - // A prefix matching a whole family distinguishes nothing inside it, and - // an arbitrary capped slice is worse than none: measured against live - // books, that slice omitted the right master about a third of the time. - if extending.len() <= MAX_PREFIX_FAMILY { - for index in extending { - offer(index, CandidateRule::CatalogPrefix); - } - } else { - suppressed_family.extend(extending); + if withheld.is_empty() { + for index in &extending { + offer(*index, CandidateRule::CatalogPrefix); } - // One pass, carrying the character count forward. Recomputing - // `chars().count()` per prefix made this quadratic in the name length, - // and the source parser admits 4 KiB fields. + } + + // Weaker rules must not reinstate what the prefix pass withheld. A token + // shared across a family *is* the family, and re-listing 25 of them is the + // arbitrary slice the withholding exists to prevent — reachable whenever + // the family stays under the common-token threshold, as 30 rows in a + // 330-master catalog do. + if !entity.key.is_empty() { + // One pass over character boundaries; recomputing a prefix length per + // split made this quadratic in a field the source parser admits at 4 KiB. for (characters, (split, _)) in entity.key.char_indices().enumerate() { if characters < MIN_PREFIX_KEY_CHARS { continue; } - if let Some(holders) = catalog.by_key.get(&entity.key[..split]) { - for index in holders { - offer(*index, CandidateRule::SourcePrefix); - } + for index in catalog + .by_key + .get(&entity.key[..split]) + .into_iter() + .flatten() + .filter(|index| !withheld.contains(index)) + { + offer(*index, CandidateRule::SourcePrefix); } } } @@ -1042,26 +1064,28 @@ fn collect_candidates( if catalog.common_tokens.contains(&token) { continue; } - if let Some(holders) = catalog.by_token.get(&token) { - for index in holders { - offer(*index, CandidateRule::SharedToken); - } + for index in catalog + .by_token + .get(&token) + .into_iter() + .flatten() + .filter(|index| !withheld.contains(index)) + { + offer(*index, CandidateRule::SharedToken); } } - // The reported total is the union: a suppressed family and the candidates - // still worth listing are not necessarily the same masters, so taking the - // larger of the two counts would under-report what the name actually - // reaches. + // The total is the union: a withheld family and the candidates still worth + // listing are not necessarily the same masters, so the larger of the two + // counts would under-report what the name reaches. let found = best .keys() .copied() - .chain(suppressed_family) + .chain(withheld) .collect::>() .len(); - // Indices, not names. Cloning every match before the cap and the budget - // discarded most of the work for an entity whose identifier is shared by - // many rows, and an admitted draft repeats that per entry. + // Indices, not names — cloning every match before the cap and the budget + // discarded most of the work, once per entry of an admitted draft. (best.into_iter().collect(), found) } diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 7151ad1f..e46466d1 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -677,7 +677,7 @@ fn the_listing_variant_says_what_an_absent_candidate_means() { let listed = ledgers(&["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA", "Beta Supply"]); let binding = bind_one_name(&listed, "ALPHA"); let candidates = &binding.unresolved().expect("unbound").candidates; - assert!(matches!(candidates, Candidates::Listed(_))); + assert!(matches!(candidates, Candidates::Listed { .. })); assert_eq!(candidates.found(), 3); } @@ -686,7 +686,7 @@ fn only_an_incomplete_listing_may_withhold_an_absence() { // The predicate a consumer needs before reporting "nothing like this is // present". `None` permits that conclusion; the other two forbid it. assert!(!Candidates::None.is_incomplete()); - assert!(!Candidates::Listed(Vec::new()).is_incomplete()); + assert!(!Candidates::Listed { listed: Vec::new() }.is_incomplete()); assert!(Candidates::Withheld { found: 30 }.is_incomplete()); assert!(Candidates::Truncated { listed: Vec::new(), @@ -731,6 +731,38 @@ fn a_prefix_matching_a_whole_family_is_counted_and_deliberately_not_listed() { assert!(unresolved.candidates.is_incomplete()); } +#[test] +fn a_weaker_rule_cannot_reinstate_a_withheld_family() { + // A token shared across a family *is* the family. Where the catalog is + // large enough that the token stays under the common-token threshold — 30 + // rows among 330 is 9% — the shared-token pass was re-offering exactly the + // rows the prefix pass had withheld, restoring the arbitrary capped slice + // the withholding exists to prevent. + let mut names = (0..30) + .map(|index| format!("Acme Branch {index:03}")) + .collect::>(); + names.extend((0..300).map(|index| format!("Unrelated Ledger {index:03}"))); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + // The scenario only exercises the path while the token stays + // discriminating: 10% of 330 is 33, and a 30-row family sits below it. + assert!( + 30 <= names.len() * COMMON_TOKEN_PERCENT / 100, + "the family would be suppressed as a common token, proving nothing" + ); + + let binding = bind_one_name(&catalog, "Acme Branch"); + let unresolved = binding.unresolved().expect("unbound"); + assert_eq!(reason(&binding), UnboundReason::NoDiscriminatingCandidate); + assert!(unresolved.candidates.listed().is_empty()); + assert_eq!(unresolved.candidates.found(), 30); + + // A decisive rule still reaches a family member on its own evidence: the + // whole key separates that one from its siblings, which is the difference + // between withholding a family and hiding a match. + let exact = bind_one_name(&catalog, "Acme Branch 017"); + assert_eq!(exact.bound_name(), Some("Acme Branch 017")); +} + #[test] fn a_family_within_the_bound_is_still_listed_in_full() { let names = (0..MAX_PREFIX_FAMILY) @@ -981,6 +1013,32 @@ fn reason_and_error_codes_are_stable_and_safe() { ); } +#[test] +fn every_unresolved_shape_survives_serialization() { + // A newtype variant under internal tagging cannot carry a sequence, and it + // failed at runtime on the *most common* unresolved result while the other + // three variants serialized fine. No test caught it because none had ever + // serialized an `Unresolved` — only a `Bound`. + let listed = ledgers(&["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA", "Beta Supply"]); + let family = (0..MAX_PREFIX_FAMILY + 5) + .map(|index| format!("ALPHAGROUP UNIT {index:02}")) + .collect::>(); + let family = MasterCatalog::new(MasterClass::Ledger, &family).expect("valid"); + let missing = ledgers(&["Alpha Traders", "Beta Supply"]); + + for (label, binding) in [ + ("listed", bind_one_name(&listed, "ALPHA")), + ("withheld", bind_one_name(&family, "ALPHAGROUP")), + ("none", bind_one_name(&missing, "Zeta Placeholder")), + ] { + let json = serde_json::to_string(&binding) + .unwrap_or_else(|error| panic!("{label} failed to serialize: {error}")); + let back: EntityBinding = serde_json::from_str(&json) + .unwrap_or_else(|error| panic!("{label} failed to deserialize: {error}")); + assert_eq!(back, binding, "{label} did not round-trip"); + } +} + #[test] fn a_bound_status_serializes_without_a_score_field() { let catalog = ledgers(&["Alpha Traders"]); From 044a7d580421ac3c618e64dbf5e52448a3dfb8a0 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 03:20:59 +0530 Subject: [PATCH 17/75] Test periods on the raw token, and let conflicting identifiers outrank a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reproduced by execution before fixing, both re-verified after, and both fixes are smaller than what they replace. `FY2025-26` canonicalizes to `FY202526`, whose six-digit run reads as no period at all, so the label became a Code identifier and `Purchases FY2025-26` bound to a sole `Sales FY2025-26`. The period test now runs on the raw token and splits on the separators operators actually write, so `FY2025` and `26` stay legible as what they are. That subsumes is_year_range, which is deleted: one period test where there were two, covering ranges the numeric path caught and the code path did not. Two identifier hints selecting two other masters, with the source name byte-matching a third, bound the name and silently discarded the conflict. A byte-exact name survives an identifier that is merely shared — the ambiguous set still contains the master the name spells — but not identifiers that all point elsewhere. The predicate is now that one sentence rather than three conditions, and the report offers every master the evidence reached, so the operator sees the disagreement rather than one side of it. Live re-measure unchanged: 434 of 434 listed rows contain the right master, median listed length 2, no wrong binds. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 96 +++++++++---------- .../src/master_binding_tests.rs | 36 +++++++ 4 files changed, 87 insertions(+), 51 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 5bee5cef..2cea617d 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "36b93b23265867e8b69a3ff288721e1a783820d88ef49d146a7e2fd26f7b8969", + "compatibility_surface_sha256": "0edebb5724ed17cc3a44bf44ce1cc1c03eb280c5d13509c0d70f9e94043a2e64", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 5a94d598..9fc6673f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "d0ef94689fd4f0d91649beb105e68aa46a64e1c6e73f116e3e8a7ea01bf18bc3" + "sha256": "1dac3c154b5234899d6715753fa639332cd6ccdb0869878985b9a4278e977eb1" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "36b93b23265867e8b69a3ff288721e1a783820d88ef49d146a7e2fd26f7b8969" + "manifest_sha256": "0edebb5724ed17cc3a44bf44ce1cc1c03eb280c5d13509c0d70f9e94043a2e64" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index fe6d3c55..636814f7 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -833,8 +833,12 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) // // Found by seeding two live ledgers that share an embedded number. No // fabricated fixture had produced the combination. - let identifier_points_elsewhere = !identifier_conflict - && identifier_matches.len() == 1 + // A byte-exact name survives an identifier that is merely *shared* — the + // ambiguous set still contains the master the name spells, so the name is + // what separates it from its siblings. It does not survive identifiers that + // all point somewhere else: that is conflicting evidence, however many of + // them there are, and preferring the name silently discards it. + let identifier_points_elsewhere = !identifier_matches.is_empty() && exact.is_some_and(|index| !identifier_matches.contains(&index)); let status = if identifier_points_elsewhere { unresolved_status( @@ -1207,7 +1211,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro if canonical.len() >= MIN_CODE_IDENTIFIER_CHARS && digits >= MIN_CODE_IDENTIFIER_DIGITS && letters >= 2 - && !is_period_label(&canonical) + && !is_period(token) { identifiers.insert(Identifier { kind: IdentifierKind::Code, @@ -1228,7 +1232,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro let digits = run.chars().filter(char::is_ascii_digit).collect::(); if digits.len() >= MIN_NUMERIC_IDENTIFIER_DIGITS && !is_plausible_date(&digits) - && !is_year_range(run) + && !is_period(run) { identifiers.insert(Identifier { kind: IdentifierKind::Numeric, @@ -1244,23 +1248,40 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro } /// A period label identifies a period, not a party or an item. Two unrelated -/// ledgers routinely share one — `Purchases FY2025` and `Sales FY2025`, -/// `Purchases SEPTEMBER2025` and `Sales SEPTEMBER2025` — and identifier-first -/// matching would bind the source to whichever exists before comparing names. +/// ledgers routinely share one, and identifier-first matching would bind the +/// source to whichever exists before it ever compared the names. /// -/// The test is on the **numbers**, not on the words: a token is a period label -/// when it carries at least one number and **every** number in it reads as a -/// year or a small ordinal. Capping the length of the alphabetic run was the -/// previous attempt and it kept losing to longer spellings — `APR2025` was -/// caught while `SEPTEMBER2025` and `2025QUARTER1` walked through. A month name -/// can be any length; a year cannot. +/// Applied to the **raw token**, because operators write ranges with the very +/// separators canonicalization strips: `FY2025-26` fuses to `FY202526`, whose +/// six-digit run reads as no period at all, and the label walked straight into +/// being a code. Splitting on the separator first keeps `FY2025` and `26` +/// legible as what they are. /// -/// An identity-bearing code survives this because its digits do not read as -/// periods: `PH01AB00` carries `00`, `AB12345678` carries an eight-digit run, -/// and a registration number carries something no calendar would produce. Like +/// The test is on the **numbers**, not the words: every part carries only +/// alphabetic markers and numbers that read as a year or a small ordinal, and +/// at least one number appears. Capping the length of the alphabetic run was an +/// earlier attempt that kept losing to longer spellings — a month name can be +/// any length; a year cannot. +/// +/// An identity-bearing code survives: `PH-01A-B00` splits to a `PH` carrying no +/// number at all, and `AB12345678` holds a run no calendar would produce. Like /// every exclusion here it can only make a bind *less* likely. -fn is_period_label(canonical: &str) -> bool { - let mut has_number = false; +fn is_period(token: &str) -> bool { + let mut any_number = false; + for part in token.split(['-', '/']) { + let canonical = part + .chars() + .filter(char::is_ascii_alphanumeric) + .map(|character| character.to_ascii_uppercase()) + .collect::(); + if canonical.is_empty() || !part_reads_as_period(&canonical, &mut any_number) { + return false; + } + } + any_number +} + +fn part_reads_as_period(canonical: &str, any_number: &mut bool) -> bool { let mut rest = canonical; while !rest.is_empty() { let alphabetic = rest.starts_with(|character: char| character.is_ascii_alphabetic()); @@ -1272,39 +1293,18 @@ fn is_period_label(canonical: &str) -> bool { if alphabetic { continue; } - if !reads_as_period_number(run) { + let value = run.parse::().unwrap_or(u32::MAX); + let reads_as_period = match run.len() { + 1 | 2 => (1..=99).contains(&value), + 4 => (1900..=2199).contains(&value), + _ => false, + }; + if !reads_as_period { return false; } - has_number = true; - } - has_number -} - -/// A year, or a small ordinal such as a month or quarter. -fn reads_as_period_number(run: &str) -> bool { - let value = run.parse::().unwrap_or(u32::MAX); - match run.len() { - 1 | 2 => (1..=99).contains(&value), - 4 => (1900..=2199).contains(&value), - _ => false, - } -} - -/// `2025-2026` and `2025/2026` are fiscal years, which two unrelated ledgers -/// share as routinely as they share a month. Stripping the separator turned -/// them into an eight-digit run that no calendar-date reading rejects, so the -/// range has to be recognized before the digits are fused. -fn is_year_range(run: &str) -> bool { - let mut halves = run.split(['-', '/']); - match (halves.next(), halves.next(), halves.next()) { - (Some(first), Some(second), None) => [first, second].iter().all(|half| { - half.len() == 4 - && half - .parse::() - .is_ok_and(|year| (1900..=2199).contains(&year)) - }), - _ => false, + *any_number = true; } + true } /// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index e46466d1..55fa33a3 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -516,6 +516,12 @@ fn a_fiscal_period_label_is_not_an_identity_bearing_code() { "SEPTEMBER2025", "2025QUARTER1", "DECEMBER2026", + // Ranges: operators write these with the very separators that + // canonicalization strips, so the test has to see the raw token. + "FY2025-26", + "FY2025/26", + "2025-2026", + "APR2025-MAR2026", ] { assert!( entity(&format!("Purchases {label}")) @@ -589,6 +595,36 @@ fn a_token_carrying_letters_never_yields_a_standalone_number() { assert_eq!(entity("Party (5550001001)").identifiers().len(), 1); } +#[test] +fn conflicting_identifiers_outrank_a_byte_exact_name_but_a_shared_one_does_not() { + // Two hints selecting two other masters is conflicting evidence, and + // binding the name silently discarded it. A *shared* identifier is + // different: the ambiguous set still contains the master the name spells, + // so the name is what separates it from its siblings. + let catalog = ledgers(&["ACME", "BETA 11111111", "GAMMA 22222222"]); + let source = + SourceEntity::with_identifier_hints(0, "ACME", ["11111111", "22222222"]).expect("valid"); + let report = bound(&catalog, &[source]); + let binding = &report.entities()[0]; + assert_eq!(reason(binding), UnboundReason::IdentifierNameConflict); + // Every master the evidence reached is offered, so the operator sees the + // disagreement rather than one side of it. + assert_eq!( + candidate_names(binding), + ["BETA 11111111", "GAMMA 22222222", "ACME"] + ); + + // The shared-identifier case must keep binding. + let shared = ledgers(&[ + "MB PARTY DELTA (5550001009)", + "MB PARTY EPSILON (5550001009)", + ]); + assert_eq!( + bind_one_name(&shared, "MB PARTY DELTA (5550001009)").bound_name(), + Some("MB PARTY DELTA (5550001009)") + ); +} + #[test] fn a_fiscal_year_range_is_a_period_not_an_account_number() { // `2025-2026` strips to an eight-digit run that no calendar reading From bc802226cc09685e3d5ebc67a8f4f4b8e30f15b9 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 03:26:44 +0530 Subject: [PATCH 18/75] Treat a non-ASCII name as a name, and a mask as identifying nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two wrong binds, both reproduced before fixing and both re-verified after, and both the same class one level down: an identifier built from something that is not identity. The letter guard was ASCII-only, so `पार्टी12345678` read as digits standing alone and bound a party to an unrelated `Bank 12345678`. The observed books carry Devanagari, Tamil and Bengali ledger names, so this was reachable on the corpus this PR already reads. The guard is now Unicode alphabetic. `XXXXX1234X` cleared every length and composition test — ten characters, six letters, four digits, no period — while carrying only a last four that any number of parties share, so two unrelated ledgers with the same mask bound to each other. A token whose letters are a single repeated character is a mask; an identity-bearing code has distinct letters. TEST_CORPUS.md §9 recorded live counts without the confidence marker AGENTS.md requires, so the seeding, the coverage counts, the rule's live behaviour and the general safety claim are now separated into VERIFIED, VERIFIED, PARTIAL and UNVERIFIED with the scope of each. The strongest claim in that section was never the one a reader would have taken from it. Live re-measure unchanged: 434 of 434 listed rows contain the right master, no wrong binds. Co-Authored-By: Claude Opus 5 --- docs/tally/TEST_CORPUS.md | 10 +++++++ .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +-- .../bridge-tally-core/src/master_binding.rs | 26 ++++++++++++++++++- .../src/master_binding_tests.rs | 25 ++++++++++++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/docs/tally/TEST_CORPUS.md b/docs/tally/TEST_CORPUS.md index 199b2d15..1f884162 100644 --- a/docs/tally/TEST_CORPUS.md +++ b/docs/tally/TEST_CORPUS.md @@ -351,6 +351,16 @@ bytes, so it cannot support any claim about the exact bytes a real instance rece ## 9. Master-binding ledgers in `BRIDGE CORPUS OPENING` +**VERIFIED 2026-09-10** for the seeding and the coverage counts; the binding behaviour built on +them is **PARTIAL**. Scope of each, so neither is read for more than it covers: + +| claim | confidence | what establishes it | +| --- | --- | --- | +| The ten ledgers exist in that company and nowhere else | **VERIFIED** | `CREATED=10, ALTERED=0, ERRORS=0`, then a readback of the ledger list naming all ten, plus a readback of a guard company showing none | +| No book carried an embedded identifier before this | **VERIFIED** | all 16 loaded companies read through the `StandardLedgerCatalogV1` request, responses written to files and parsed from the files; 470 names, 0 numeric and 1 code identifier | +| The identifier rule behaves correctly against live-read names | **PARTIAL** | exercised against these ten seeded names only, on one instance, one licence tier, one Tally build. Fabricated *source* names against live *catalogue* names — no real source document has been bound end to end | +| Binding is safe on catalogues generally | **UNVERIFIED** | no engagement has run through this code path; the mutation sweep is fabricated mutations of live names, not observed operator input | + **Added 2026-09-10.** Ten ledgers prefixed `MB `, seeded so the master-binding identifier rule has live coverage. Before this, **no book on either instance carried an embedded identifier**: across 470 live ledger names read from all 16 loaded companies, diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 2cea617d..48fe68b0 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "0edebb5724ed17cc3a44bf44ce1cc1c03eb280c5d13509c0d70f9e94043a2e64", + "compatibility_surface_sha256": "0adf444c0af9ea4b8937f2a061b25e8e2ef82cbfcf45048deccca62f3950a72f", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 9fc6673f..d2603895 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "1dac3c154b5234899d6715753fa639332cd6ccdb0869878985b9a4278e977eb1" + "sha256": "457e63b990e57a55e8dd2f448b03ca70aeea9cffd31dd8a8dca15841098cb1fa" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "0edebb5724ed17cc3a44bf44ce1cc1c03eb280c5d13509c0d70f9e94043a2e64" + "manifest_sha256": "0adf444c0af9ea4b8937f2a061b25e8e2ef82cbfcf45048deccca62f3950a72f" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 636814f7..0921d977 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1212,6 +1212,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro && digits >= MIN_CODE_IDENTIFIER_DIGITS && letters >= 2 && !is_period(token) + && !is_masked(&canonical) { identifiers.insert(Identifier { kind: IdentifierKind::Code, @@ -1223,7 +1224,12 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro // reach an unrelated `Bank 12345678` through the one-letter gap that // the code test rejects — a token either identifies by its whole shape // or not at all. - if letters > 0 { + // + // The test is **Unicode alphabetic**, not ASCII. The books observed + // here carry Devanagari, Tamil and Bengali ledger names, and an + // ASCII-only guard read `पार्टी12345678` as digits standing alone, + // binding a party to an unrelated `Bank 12345678`. + if token.chars().any(char::is_alphabetic) { continue; } for run in token.split(|character: char| { @@ -1247,6 +1253,24 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro Ok(identifiers.into_iter().collect()) } +/// A masked value exposes a non-unique suffix and identifies nothing. +/// +/// `XXXXX1234X` clears every length and composition test — ten characters, six +/// letters, four digits, no period — while the only information in it is a last +/// four that any number of parties share. Two unrelated ledgers carrying the +/// same mask would bind to each other. +/// +/// Recognized by its letters being a single repeated character, which is what a +/// mask is and what an identity-bearing code never is: `PH01AB00` and a +/// registration number both carry distinct letters. +fn is_masked(canonical: &str) -> bool { + let mut letters = canonical.chars().filter(char::is_ascii_alphabetic); + match letters.next() { + Some(first) => letters.all(|letter| letter == first), + None => false, + } +} + /// A period label identifies a period, not a party or an item. Two unrelated /// ledgers routinely share one, and identifier-first matching would bind the /// source to whichever exists before it ever compared the names. diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 55fa33a3..e03f7078 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -625,6 +625,31 @@ fn conflicting_identifiers_outrank_a_byte_exact_name_but_a_shared_one_does_not() ); } +#[test] +fn a_non_ascii_name_beside_digits_is_still_a_name() { + // The observed books carry Devanagari, Tamil and Bengali ledger names. An + // ASCII-only letter guard read `पार्टी12345678` as digits standing alone + // and bound a party to an unrelated bank ledger. + let party = "\u{92a}\u{93e}\u{930}\u{94d}\u{91f}\u{940}12345678"; + assert!(entity(party).identifiers().is_empty()); + let catalog = ledgers(&["Bank 12345678", "Beta Supply"]); + assert_eq!(bind_one_name(&catalog, party).bound_name(), None); +} + +#[test] +fn a_masked_value_identifies_nothing() { + // `XXXXX1234X` clears every length and composition test while carrying only + // a last four that any number of parties share. + assert!(entity("Purchases XXXXX1234X").identifiers().is_empty()); + let catalog = ledgers(&["Sales XXXXX1234X", "Beta Supply"]); + assert_eq!( + bind_one_name(&catalog, "Purchases XXXXX1234X").bound_name(), + None + ); + // Distinct letters are what an identity-bearing code has and a mask does not. + assert_eq!(entity("Item PH01AB00").identifiers().len(), 1); +} + #[test] fn a_fiscal_year_range_is_a_period_not_an_account_number() { // `2025-2026` strips to an eight-digit run that no calendar reading From 1406adaf437b6c30fb45bd96fdf53637bf017927 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:04:22 +0530 Subject: [PATCH 19/75] Keep provenance per identifier rather than flattening it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third finding on the same predicate, so the fix is the structural one rather than a fourth condition. The predicate asked whether the *union* of matched masters contains the byte-exact one. That answers the shared case correctly — one number on two masters, where the name separates them — and the mixed case wrongly: `ACME 11111111` with a hint reaching `BETA 22222222` has the exact master in the union because its own number is one of the identifiers, while a second identifier plainly disagrees. Flattening identifier-to-master provenance into one set discarded the only fact that separates those two, and they need opposite answers. The match is now kept per identifier, and a byte-exact name is outranked when any single identifier reached somewhere else. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +-- .../bridge-tally-core/src/master_binding.rs | 33 ++++++++++++++----- .../src/master_binding_tests.rs | 17 +++++++++- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 48fe68b0..8591df77 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "0adf444c0af9ea4b8937f2a061b25e8e2ef82cbfcf45048deccca62f3950a72f", + "compatibility_surface_sha256": "7b4301713e4f9d3f888dc7650c8f59f742b330763bdc916d54a80fc5bdebcd0a", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index d2603895..62aa9f76 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "457e63b990e57a55e8dd2f448b03ca70aeea9cffd31dd8a8dca15841098cb1fa" + "sha256": "9175af95eabdf88a72475e23ef601ba11154c2abf98613ae1e30f26bafa49aa6" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "0adf444c0af9ea4b8937f2a061b25e8e2ef82cbfcf45048deccca62f3950a72f" + "manifest_sha256": "7b4301713e4f9d3f888dc7650c8f59f742b330763bdc916d54a80fc5bdebcd0a" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 0921d977..6b718bbe 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -809,14 +809,21 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) // Rule one: the identifier is the key, the name is a hint. A name // comparison on a pair that carries a decisive identifier is not merely // weaker evidence, it is actively misleading. + // Which masters *each* identifier reached, not merely which masters were + // reached. Flattening the two loses the only fact that separates a number + // shared by several masters from several numbers pointing at different + // ones, and those need opposite answers. + let mut per_identifier: Vec> = Vec::new(); let mut identifier_matches = BTreeSet::new(); let mut identifier_conflict = false; for identifier in &entity.identifiers { if let Some(holders) = catalog.by_identifier.get(identifier) { - if holders.len() > 1 { + let reached = holders.iter().copied().collect::>(); + if reached.len() > 1 { identifier_conflict = true; } - identifier_matches.extend(holders.iter().copied()); + identifier_matches.extend(reached.iter().copied()); + per_identifier.push(reached); } } @@ -833,13 +840,21 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) // // Found by seeding two live ledgers that share an embedded number. No // fabricated fixture had produced the combination. - // A byte-exact name survives an identifier that is merely *shared* — the - // ambiguous set still contains the master the name spells, so the name is - // what separates it from its siblings. It does not survive identifiers that - // all point somewhere else: that is conflicting evidence, however many of - // them there are, and preferring the name silently discards it. - let identifier_points_elsewhere = !identifier_matches.is_empty() - && exact.is_some_and(|index| !identifier_matches.contains(&index)); + // A byte-exact name survives an identifier that is merely *shared*: that + // one identifier reached the master the name spells along with its + // siblings, and the name is what separates them. It does not survive an + // identifier that reached somewhere else entirely — that is disagreement, + // and preferring the name silently discards it. + // + // The test is per identifier, not over their union. Asking whether the + // union contains the exact master answers the shared case correctly and the + // mixed case wrongly: `ACME 11111111` with a hint reaching `BETA 22222222` + // has the exact master in the union while one identifier plainly disagrees. + let identifier_points_elsewhere = exact.is_some_and(|index| { + per_identifier + .iter() + .any(|reached| !reached.contains(&index)) + }); let status = if identifier_points_elsewhere { unresolved_status( catalog, diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index e03f7078..6bcedc43 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -614,7 +614,9 @@ fn conflicting_identifiers_outrank_a_byte_exact_name_but_a_shared_one_does_not() ["BETA 11111111", "GAMMA 22222222", "ACME"] ); - // The shared-identifier case must keep binding. + // The shared-identifier case must keep binding: one identifier reached the + // master the name spells along with its sibling, and the name separates + // them. let shared = ledgers(&[ "MB PARTY DELTA (5550001009)", "MB PARTY EPSILON (5550001009)", @@ -623,6 +625,19 @@ fn conflicting_identifiers_outrank_a_byte_exact_name_but_a_shared_one_does_not() bind_one_name(&shared, "MB PARTY DELTA (5550001009)").bound_name(), Some("MB PARTY DELTA (5550001009)") ); + + // The mixed case, which a union test answers wrongly: the exact master is + // in the union because its own number is one of the identifiers, while a + // second identifier plainly reaches somewhere else. Provenance per + // identifier is the only thing that separates this from the shared case. + let mixed = ledgers(&["ACME 11111111", "BETA 22222222"]); + let source = + SourceEntity::with_identifier_hints(0, "ACME 11111111", ["22222222"]).expect("valid"); + let report = bound(&mixed, &[source]); + assert_eq!( + reason(&report.entities()[0]), + UnboundReason::IdentifierNameConflict + ); } #[test] From 34212664af457f43d9ef8693ec658e0b3390a545 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:11:07 +0530 Subject: [PATCH 20/75] Reject mask punctuation before extracting a numeric identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_masked` guards the code branch, where a mask spelled with letters is caught by the letter test anyway. A mask spelled with punctuation never reaches it: in the numeric branch every non-digit is an ordinary delimiter, so `********12345678` split cleanly and offered its visible suffix as though it were the whole account, binding two unrelated ledgers that share it. A value written with mask punctuation is partial by construction, so what it exposes is a suffix and not the number. Ordinary punctuation around a whole number is untouched — `(5550001001)` and `5550001-002` still bind — because a fix that rejected all punctuation would have been quietly worse than the bug. Fourth finding in the same family: an identifier built from something that is not identity. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 ++-- .../bridge-tally-core/src/master_binding.rs | 16 +++++++++++++++- .../src/master_binding_tests.rs | 18 ++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 8591df77..59f3d563 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "7b4301713e4f9d3f888dc7650c8f59f742b330763bdc916d54a80fc5bdebcd0a", + "compatibility_surface_sha256": "468c5b38d5300be99b9e0761e929fbecba69ea89b505e35396b8519a129c3a7d", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 62aa9f76..51716d05 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "9175af95eabdf88a72475e23ef601ba11154c2abf98613ae1e30f26bafa49aa6" + "sha256": "88a88e1a268d950b68929c0df0729e86f556f60152be2c935789983f319376f7" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "7b4301713e4f9d3f888dc7650c8f59f742b330763bdc916d54a80fc5bdebcd0a" + "manifest_sha256": "468c5b38d5300be99b9e0761e929fbecba69ea89b505e35396b8519a129c3a7d" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 6b718bbe..c2db73df 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1244,7 +1244,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro // here carry Devanagari, Tamil and Bengali ledger names, and an // ASCII-only guard read `पार्टी12345678` as digits standing alone, // binding a party to an unrelated `Bank 12345678`. - if token.chars().any(char::is_alphabetic) { + if token.chars().any(char::is_alphabetic) || is_mask_punctuated(token) { continue; } for run in token.split(|character: char| { @@ -1268,6 +1268,20 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro Ok(identifiers.into_iter().collect()) } +/// A value written with mask punctuation is partial by construction, so the +/// digits it does expose are a suffix rather than the number. +/// +/// `********12345678` split cleanly on the asterisks and yielded its visible +/// eight digits as though they were the whole account. `is_masked` guards the +/// code branch only, because a mask spelled with letters is caught by the +/// letter test; a mask spelled with punctuation reaches the numeric branch, +/// where every non-digit is an ordinary delimiter. +fn is_mask_punctuated(token: &str) -> bool { + token + .chars() + .any(|character| matches!(character, '*' | '#' | '\u{2022}' | '\u{00d7}')) +} + /// A masked value exposes a non-unique suffix and identifies nothing. /// /// `XXXXX1234X` clears every length and composition test — ten characters, six diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 6bcedc43..f7886c45 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -663,6 +663,24 @@ fn a_masked_value_identifies_nothing() { ); // Distinct letters are what an identity-bearing code has and a mask does not. assert_eq!(entity("Item PH01AB00").identifiers().len(), 1); + + // A mask spelled with punctuation reaches the numeric branch instead, where + // every non-digit is an ordinary delimiter — so `********12345678` split + // cleanly and offered its visible suffix as though it were the account. + for masked in ["Purchases ********12345678", "Purchases ####12345678"] { + assert!( + entity(masked).identifiers().is_empty(), + "{masked} exposed its suffix as an identifier" + ); + } + let punctuated = ledgers(&["Sales ********12345678", "Beta Supply"]); + assert_eq!( + bind_one_name(&punctuated, "Purchases ********12345678").bound_name(), + None + ); + // Ordinary punctuation around a whole number is not a mask. + assert_eq!(entity("Party (5550001001)").identifiers().len(), 1); + assert_eq!(entity("Party 5550001-002").identifiers().len(), 1); } #[test] From 01d89f0dfdf21debe0881e9141b5630a7a3a2f9c Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:37:50 +0530 Subject: [PATCH 21/75] Stop tearing Indic names apart at their joins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tokenisation split on "not alphanumeric", and `char::is_alphanumeric` is false for a Devanagari virama — the halant that joins consonants — and false for a nukta. So Indic ledger names were cut at exactly the character that holds a word together: `राय एण्ड सन्स` yielded one usable token instead of three, `ट्रेडर्स` became a fragment, and a Tamil name lost its tail. Those names are in the books this binder already reads. The separator set is now defined positively — whitespace, and ASCII punctuation — so an ASCII class decides only questions about ASCII characters and everything outside it is word content, marks and joiners included. That keeps the rule right for scripts nobody here has tested. This only ever degraded candidate quality, never caused a wrong bind: shared tokens surface candidates and do not decide. But it degraded it precisely for the names an Indian firm would actually use. Verified against a positive control: with the old predicate the new test finds no candidates at all. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 ++-- .../bridge-tally-core/src/master_binding.rs | 24 +++++++++++++++---- .../src/master_binding_tests.rs | 23 ++++++++++++++++++ 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 59f3d563..1f36e371 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "468c5b38d5300be99b9e0761e929fbecba69ea89b505e35396b8519a129c3a7d", + "compatibility_surface_sha256": "1bd7ab994ab29258171a1813ac72d2e12a39dcb6bc94fed0d6fdce9c06572f4d", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 51716d05..92dfbbfc 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "88a88e1a268d950b68929c0df0729e86f556f60152be2c935789983f319376f7" + "sha256": "a10ab63dce7382f15dfa7ca8d3d692dd8583edd3312e6ea36cf14410dba060e2" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "468c5b38d5300be99b9e0761e929fbecba69ea89b505e35396b8519a129c3a7d" + "manifest_sha256": "1bd7ab994ab29258171a1813ac72d2e12a39dcb6bc94fed0d6fdce9c06572f4d" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index c2db73df..3b756060 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1194,11 +1194,27 @@ fn master_identity_key(value: &str) -> String { .join(" ") } +/// Splits a comparison key into words. +/// +/// The separator set is defined **positively** — whitespace, and ASCII +/// punctuation — rather than as "not alphanumeric". The difference is not +/// pedantic: `char::is_alphanumeric` is **false for a Devanagari virama**, the +/// halant that joins consonants, and false for a nukta. Splitting on it tore +/// Indic ledger names apart at the joins, so `राय एण्ड सन्स` yielded one usable +/// token instead of three and `ट्रेडर्स` was cut to a fragment. Those names are +/// in the books this binder reads. +/// +/// So: an ASCII class may decide a question about ASCII characters, and +/// everything outside ASCII — letters, digits, marks, joiners alike — is word +/// content. That keeps the rule correct for scripts nobody here has tested, +/// which is the property worth having. fn tokens_of(key: &str) -> BTreeSet { - key.split(|character: char| !character.is_alphanumeric()) - .filter(|token| token.chars().count() >= MIN_TOKEN_CHARS) - .map(str::to_string) - .collect() + key.split(|character: char| { + character.is_whitespace() || (character.is_ascii() && !character.is_ascii_alphanumeric()) + }) + .filter(|token| token.chars().count() >= MIN_TOKEN_CHARS) + .map(str::to_string) + .collect() } /// Extracts every identifier a name carries, in canonical form. diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index f7886c45..e84c722b 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -640,6 +640,29 @@ fn conflicting_identifiers_outrank_a_byte_exact_name_but_a_shared_one_does_not() ); } +#[test] +fn an_indic_name_is_not_torn_apart_at_its_joins() { + // `char::is_alphanumeric` is false for a Devanagari virama — the halant + // that joins consonants — and false for a nukta. Splitting on "not + // alphanumeric" cut these names at the joins, so a shared word stopped + // being a shared token. These names are in the books this binder reads. + let catalog = ledgers(&[ + "\u{936}\u{94d}\u{930}\u{940} \u{917}\u{923}\u{947}\u{936} \u{91f}\u{94d}\u{930}\u{947}\u{921}\u{930}\u{94d}\u{938}", + "\u{930}\u{93e}\u{92f} \u{90f}\u{923}\u{94d}\u{921} \u{938}\u{928}\u{94d}\u{938}", + "Beta Supply", + ]); + // The second book's distinctive word, which the virama used to fragment + // away entirely, now reaches its own master. + let binding = bind_one_name(&catalog, "\u{938}\u{928}\u{94d}\u{938}"); + assert!( + candidate_names(&binding) + .iter() + .any(|name| name.contains("\u{930}\u{93e}\u{92f}")), + "a shared Indic word did not surface its master: {:?}", + candidate_names(&binding) + ); +} + #[test] fn a_non_ascii_name_beside_digits_is_still_a_name() { // The observed books carry Devanagari, Tamil and Bengali ledger names. An From e97268fa2c6fb4ed18f0cfb34d02d2cbad4eff92 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:48:06 +0530 Subject: [PATCH 22/75] Mask across tokens, and split a range on the dashes we already fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more of the same family, both small. A mask and the digits it hides are often written apart: `**** 12345678` is the same statement as `********12345678`, and reading tokens independently lost the relationship, so the attached form was rejected while the separated one bound. Extraction now carries whether the preceding token was a mask. The period boundary split on ASCII `-` and `/` only, while the comparison key already folds the Unicode dash variants — so `FY2025–26` fused to `FY202526` and became an identity-bearing code where `FY2025-26` had failed closed. Both now admit the same set, named once. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 ++-- .../bridge-tally-core/src/master_binding.rs | 21 +++++++++++++++++-- .../src/master_binding_tests.rs | 15 +++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 120d8339..bf26f28b 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "ed682fad8c1502bd249456a73a8cb79c73d8ea308837a8bff7a8bf87cbf28564", + "compatibility_surface_sha256": "449b6f7f05c9f1b6a75ea2b040bd7f9c88bbd2a4a07d1f5b3b67a0df8aff7970", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index b1f26ecc..3b33edf7 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "a10ab63dce7382f15dfa7ca8d3d692dd8583edd3312e6ea36cf14410dba060e2" + "sha256": "2b870cd36175ff236f5a3fc9e6d13728e0b8e6f309c263b6739d56adbff0db20" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "ed682fad8c1502bd249456a73a8cb79c73d8ea308837a8bff7a8bf87cbf28564" + "manifest_sha256": "449b6f7f05c9f1b6a75ea2b040bd7f9c88bbd2a4a07d1f5b3b67a0df8aff7970" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 3b756060..7998a126 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1231,7 +1231,17 @@ fn tokens_of(key: &str) -> BTreeSet { /// discarding the identifier that pointed elsewhere. fn extract_identifiers(value: &str) -> Result, MasterBindingError> { let mut identifiers = BTreeSet::new(); + // A mask and the digits it hides are often written apart — `**** 12345678` + // is the same statement as `********12345678`, and reading tokens + // independently lost the relationship between them. + let mut previous_was_mask = false; for token in value.split(char::is_whitespace) { + if token.is_empty() { + continue; + } + let masked_here = is_mask_punctuated(token); + let masked = masked_here || previous_was_mask; + previous_was_mask = masked_here; let canonical = token .chars() .filter(|character| character.is_ascii_alphanumeric()) @@ -1260,7 +1270,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro // here carry Devanagari, Tamil and Bengali ledger names, and an // ASCII-only guard read `पार्टी12345678` as digits standing alone, // binding a party to an unrelated `Bank 12345678`. - if token.chars().any(char::is_alphabetic) || is_mask_punctuated(token) { + if token.chars().any(char::is_alphabetic) || masked { continue; } for run in token.split(|character: char| { @@ -1298,6 +1308,13 @@ fn is_mask_punctuated(token: &str) -> bool { .any(|character| matches!(character, '*' | '#' | '\u{2022}' | '\u{00d7}')) } +/// The separators an operator writes a range with. The comparison key already +/// folds these dash variants to ASCII; the period boundary has to admit the +/// same set, or `FY2025\u{2013}26` fuses where `FY2025-26` splits. +const DASH_VARIANTS: [char; 9] = [ + '-', '/', '\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}', '\u{2212}', +]; + /// A masked value exposes a non-unique suffix and identifies nothing. /// /// `XXXXX1234X` clears every length and composition test — ten characters, six @@ -1337,7 +1354,7 @@ fn is_masked(canonical: &str) -> bool { /// every exclusion here it can only make a bind *less* likely. fn is_period(token: &str) -> bool { let mut any_number = false; - for part in token.split(['-', '/']) { + for part in token.split(DASH_VARIANTS) { let canonical = part .chars() .filter(char::is_ascii_alphanumeric) diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index e84c722b..3df53690 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -520,6 +520,11 @@ fn a_fiscal_period_label_is_not_an_identity_bearing_code() { // canonicalization strips, so the test has to see the raw token. "FY2025-26", "FY2025/26", + // The comparison key folds these dash variants; the period boundary + // has to admit the same set or the range fuses instead of splitting. + "FY2025\u{2013}26", + "FY2025\u{2014}26", + "2025\u{2013}2026", "2025-2026", "APR2025-MAR2026", ] { @@ -701,9 +706,19 @@ fn a_masked_value_identifies_nothing() { bind_one_name(&punctuated, "Purchases ********12345678").bound_name(), None ); + // A mask and the digits it hides are often written apart; that is the same + // statement, and reading tokens independently lost the relationship. + for separated in ["Purchases **** 12345678", "Purchases #### 12345678"] { + assert!( + entity(separated).identifiers().is_empty(), + "{separated} exposed its suffix as an identifier" + ); + } // Ordinary punctuation around a whole number is not a mask. assert_eq!(entity("Party (5550001001)").identifiers().len(), 1); assert_eq!(entity("Party 5550001-002").identifiers().len(), 1); + // And a number following an ordinary word is untouched. + assert_eq!(entity("Invoice 5550001001").identifiers().len(), 1); } #[test] From 1d37a074044f68d0d440e1057d8c9c24ebeeabae Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:54:23 +0530 Subject: [PATCH 23/75] Pin prefer-exact, refuse-ambiguous, never-pick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TALLY_PROTOCOL_REFERENCE.md` §9.4b states the requirement the fold's callers must meet, after a reviewer there observed that "compare on the canonical form" alone permits an implementation weaker than the one in the tree: it says nothing about what to do when two masters collapse together, and nothing measured says which one Tally would choose. The binder already behaves correctly — verified by execution against `Alpha-Beta` and `Alpha Beta` in one catalogue, which collapse under the three verified transformations. Byte equality outranks the shared key, and with no exact spelling to prefer the collapse is reported with both masters offered rather than resolved to one. Pinning it because a fold that returns the first match is the failure mode, and correct-by-accident and correct-by-test look identical until someone simplifies the ambiguity branch. Co-Authored-By: Claude Opus 5 --- .../src/master_binding_tests.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 3df53690..2fa6d0fa 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -436,6 +436,34 @@ fn a_hyphen_matches_a_space_because_tally_says_so() { ); } +#[test] +fn masters_that_collapse_under_the_fold_are_refused_never_chosen() { + // `TALLY_PROTOCOL_REFERENCE.md` §9.4b requires prefer-exact, + // refuse-ambiguous, never-pick. `A-B` and `A B` collapse under the three + // verified transformations and nothing measured says which one Tally would + // choose, so a fold that returns the first match is the failure mode. + let catalog = ledgers(&["Alpha-Beta", "Alpha Beta", "Gamma"]); + + // Prefer-exact: byte equality outranks a key shared by two masters. + assert_eq!( + bind_one_name(&catalog, "Alpha Beta").bound_name(), + Some("Alpha Beta") + ); + assert_eq!( + bind_one_name(&catalog, "Alpha-Beta").bound_name(), + Some("Alpha-Beta") + ); + + // Refuse-ambiguous, never-pick: with no exact spelling to prefer, the + // collapse is reported with both masters offered, not resolved to one. + for spelling in ["alpha beta", "ALPHA BETA"] { + let binding = bind_one_name(&catalog, spelling); + assert_eq!(reason(&binding), UnboundReason::NameAmbiguous); + assert_eq!(binding.bound_name(), None); + assert_eq!(candidate_names(&binding), ["Alpha Beta", "Alpha-Beta"]); + } +} + #[test] fn the_master_fold_stops_where_tally_stops() { // §3.3b also measured what Tally does NOT normalise: `AND` for `&`, a From 5eebe21bffd10d143077bbaa952d8c015adffa97 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 10:09:42 +0530 Subject: [PATCH 24/75] Pin the absent-master direction of the fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prefer-exact and refuse-ambiguous cover the case where two masters collapse onto a request. They say nothing about the opposite one: the requested master is absent and a *different* ledger collapses onto the request. There is one candidate and no ambiguity to refuse, so the safeguard never fires. Verified by execution in both directions — `A & B` against a book holding only `AB`, and the reverse — plus the three §3.3b rejections read from the absent side. All refuse, because `&` stays significant in this fold. The near-miss cases still offer the collapsing ledger as a candidate, which is what a looser-than-measured comparison is allowed to do: suggest for a human, never resolve. Uniqueness under a fold is only as meaningful as the fold, and nothing in the tree was holding `&` significant on purpose until now. Co-Authored-By: Claude Opus 5 --- .../bridge-tally-core/src/master_binding_tests.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 2fa6d0fa..5b473693 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -481,6 +481,20 @@ fn the_master_fold_stops_where_tally_stops() { "{wrong:?} bound, but Tally rejects it" ); } + + // The *absent-master* direction, which prefer-exact and refuse-ambiguous + // do not cover: the requested master is not in the book and one different + // ledger collapses onto the request, so there is one candidate and no + // ambiguity to refuse. Uniqueness under a fold is only as meaningful as + // the fold, and `&` has to stay significant for this to hold. + for (requested, present) in [("A & B", "AB"), ("AB", "A & B")] { + let only = ledgers(&[present, "Gamma"]); + assert_eq!( + bind_one_name(&only, requested).bound_name(), + None, + "{requested:?} bound to {present:?}, which Tally treats as a different master" + ); + } } #[test] From 9e34cd7784a4e68a88aacd788285b0e5155cd3a9 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 11 Sep 2026 11:06:29 +0530 Subject: [PATCH 25/75] fix(master-binding): close four identifier holes and prove the path live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six review findings, each reproduced against the code before it was changed. Four are one family: an identifier was manufactured from something that identifies nothing, and a sole live master carrying the same manufactured value then bound to it. - A mask spelled with letters lost its mask when written a space from its digits: `XXXX 12345678` yielded the suffix as a whole account number. Both mask spellings now suppress the token that follows, for both identifier shapes. - A token holding non-ASCII letters shed them during canonicalization and yielded a code the name never contained, so a party name in another script reached an unrelated bank ledger while its ASCII spelling did not. Such a token now yields no code. - A fiscal range written without its separator — `FY202425`, `FY20242025` — arrived as one run that every length test missed and passed as a code. A year followed by a year now reads as the period it is. - The identifier-hint bound was checked on the deduplicated set, so repeated hints folded to one identifier and the bound never fired while every hint had already been scanned and copied. Bounded as the hints arrive. The remaining two are the type-level guarantee and the evidence. - `BindingReport` no longer derives `Deserialize`. `catalog.fingerprint()` is public, so a restored report could forge the provenance `assign_fallback` checks and draw a fallback for an entity the binder never emitted. `bind` is now the only way to obtain one, and forging is a compile error. - The binder had never run through the surface that ships it. It has now: the branch's own `bridge_mcp` binary driven over a real MCP session against licensed TallyPrime 7.1, reading a live catalogue over the wire and returning the binder's report. Ten source names reach every decision the binder makes. Recorded in TEST_CORPUS.md §9 with what it does and does not establish. That slice also showed the fold binding on a transformation TALLY_PROTOCOL_REFERENCE.md §9.4b marks UNVERIFIED. ADR 0016 §3 claimed the fold "stops exactly where Tally stops"; it does not, and four of its steps are Bridge policy rather than measured Tally behaviour. The citation is corrected to §9.4b in both the ADR and the code, the width is argued on its own terms with the two guards that carry it, and the open trade is recorded rather than settled. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 62 ++++++++-- docs/tally/TEST_CORPUS.md | 48 +++++++- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 110 +++++++++++++++--- .../src/master_binding_tests.rs | 103 +++++++++++++++- 6 files changed, 295 insertions(+), 34 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index f8e553e4..b2febc07 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -65,7 +65,12 @@ The constructor refuses, rather than degrades, on: does not identify one master, nothing downstream is meaningful; - **an identifier hint that yields no identifier** — `IdentifierHintUnusable`. A hint that silently does nothing is a trap (P7); -- bounds violations on entry count, entity count, and name length. +- bounds violations on entry count, entity count, name length, and **hint + count**. The last is checked as the hints arrive rather than on the finished + set: hints deduplicate, so a million repeated ones fold to a single identifier + and the finished set never exceeds its bound, while every one of them has + already been scanned and copied. Each hint yields at least one identifier or + is refused outright, so the eager bound rejects nothing the late one admitted. A softer collision — two masters differing only in case, whitespace runs, or dash and quote style — does **not** fail the catalog. It is carried as a @@ -117,13 +122,32 @@ it qualified as a code. Otherwise `Part A12345678` reaches an unrelated `Bank 12345678` through the one-letter gap the code test rejects: a token identifies by its whole shape or not at all. +**A mask is a mask however it is spelled, and wherever it is written.** A value +carrying mask punctuation (`****`, `####`) or a run of one repeated letter +(`XXXX`) exposes a suffix rather than a number, and that suffix is no more +identifying a space away than joined: `XXXX 12345678` is the same statement as +`XXXX12345678`. A mask therefore suppresses the token that follows it, in both +spellings and for both identifier shapes. Two unrelated ledgers sharing a masked +last-eight must reach a near-miss, never a bind. + +**A code is a code only in the script it is written in.** Canonical form keeps +ASCII alphanumerics alone, so a name in another script fused to an ASCII suffix +would shed its letters and yield a code the name never contained, binding a +party to an unrelated bank where the ASCII spelling of the same shape did not. +A token holding non-ASCII letters yields no code. This rule has to hold in every +script or the boundary is an ASCII boundary wearing a general name, and the +books this binder reads carry Devanagari, Tamil and Bengali ledger names. + **Period labels are recognized by their numbers, not their words.** A token is a period when every number in it reads as a year or a small ordinal — which catches `SEPTEMBER2025` and `2025QUARTER1` that no cap on the alphabetic run ever would, because a month name can be any length and a year cannot. A fiscal range (`2025-2026`, `2025/2026`) is excluded before its digits are fused, since stripping the separator produced an eight-digit run that no calendar reading -rejects. +rejects. Written without any separator the range arrives as one run that the +splitting step never sees — `FY202425`, `FY20242025` — so a year followed by a +two- or four-digit year is read as a period in its own right. Otherwise a +missing `Purchases FY202425` identifier-binds to a sole live `Sales FY202425`. One narrow exclusion applies to the numeric shape: an eight-digit run that reads as a calendar date in 1900–2199 is a date, not an identifier. Without it two @@ -160,12 +184,32 @@ favour. under **Tally's own rule for when two master names are the same**, and only when exactly one master shares it. -That rule is measured, not chosen: `IMPLEMENTATION_GUIDE.md` §3.3b found Tally's -master-name matching to be case-insensitive **and separator-insensitive — a -hyphen matches a space** — and otherwise exact on letters. `AND` for `&`, a -missing suffix word, and a singular for a plural were all rejected. So the fold -lowercases, collapses whitespace, folds Unicode dash and quote variants to -ASCII, and treats `-` as a space; and it stops exactly where Tally stops. +**Part of that rule is measured, and the part that is not has to say so.** +`TALLY_PROTOCOL_REFERENCE.md` §9.4b sent named variants at a live master and +recorded which Tally accepted: ASCII case folding, one trailing space, and a +**space supplied where the master carries a hyphen**. `AND` for `&`, a missing +suffix word and a singular for a plural were rejected. Those three are Tally's +behaviour; §9.4b marks everything else **UNVERIFIED** and warns that a fold is +only as safe as its least-verified step. + +This fold is wider. It also folds the reverse hyphen direction, collapses runs +of internal whitespace, ignores leading whitespace, and folds Unicode dash and +quote variants to ASCII — four transformations on §9.4b's unverified list. An +earlier draft of this section claimed the fold "stops exactly where Tally +stops". That was wrong, and the live slice in `TEST_CORPUS.md` §9 shows it +binding on the unverified reverse direction against a real instance. + +**So the extra width is Bridge's policy, not Tally's, and stands on its own +argument:** a bind answers *which master the operator meant*, and two spellings +differing only in separators are one name to whoever typed either. Two guards +carry that. A fold merging two **live** masters never resolves — the pair is an +ambiguity and both surface (§4). And the write gate admits `exact` only, so a +normalized bind informs an operator without widening what may be written. + +It is nevertheless the least-proven step in this module. §9.4b's own remedy is +open — let the verified three resolve and a looser fold only *suggest* — and +taking it would reinstate the refusals the next paragraph argues against. That +trade is recorded here, not settled here. **Being stricter than the authority is not the safe direction it appears to be.** It refuses names Tally would accept, and `X - Y` is a common ledger @@ -174,7 +218,7 @@ that shape. A binder that reports a near-miss for a name the book would have matched has invented work, not prevented an error. This fold is deliberately **separate from the general comparison key**, which is -shared with other contracts for voucher numbers and voucher-type names. §3.3b +shared with other contracts for voucher numbers and voucher-type names. §9.4b says nothing about those, and widening the shared fold to serve masters would be the "never to make one caller's case pass" this ADR warns against. One fold per notion of sameness, each named for the question it answers. Nothing else binds. There is no edit distance, no diff --git a/docs/tally/TEST_CORPUS.md b/docs/tally/TEST_CORPUS.md index 1f884162..346152f5 100644 --- a/docs/tally/TEST_CORPUS.md +++ b/docs/tally/TEST_CORPUS.md @@ -359,7 +359,8 @@ them is **PARTIAL**. Scope of each, so neither is read for more than it covers: | The ten ledgers exist in that company and nowhere else | **VERIFIED** | `CREATED=10, ALTERED=0, ERRORS=0`, then a readback of the ledger list naming all ten, plus a readback of a guard company showing none | | No book carried an embedded identifier before this | **VERIFIED** | all 16 loaded companies read through the `StandardLedgerCatalogV1` request, responses written to files and parsed from the files; 470 names, 0 numeric and 1 code identifier | | The identifier rule behaves correctly against live-read names | **PARTIAL** | exercised against these ten seeded names only, on one instance, one licence tier, one Tally build. Fabricated *source* names against live *catalogue* names — no real source document has been bound end to end | -| Binding is safe on catalogues generally | **UNVERIFIED** | no engagement has run through this code path; the mutation sweep is fabricated mutations of live names, not observed operator input | +| The shipped consumer path runs end to end against a real instance | **VERIFIED 2026-09-11** | the branch's own `bridge_mcp` binary driven over stdio against licensed TallyPrime 7.1 Silver, reading this company's catalogue over the wire and returning the binder's report — see "The end-to-end slice" below | +| Binding is safe on catalogues generally | **UNVERIFIED** | one company, one instance, one Tally build, and every *source* name fabricated. No engagement has run a real document through this path; the mutation sweep is fabricated mutations of live names, not observed operator input | **Added 2026-09-10.** Ten ledgers prefixed `MB `, seeded so the master-binding identifier rule has live coverage. Before this, **no book on either instance carried an @@ -396,6 +397,51 @@ confirmed by a readback of the ledger list — counters alone prove nothing, sin rewrites imports silently. **Do not re-send the create file:** an identical `Create` is a silent `Alter` that overwrites. +## The end-to-end slice, 2026-09-11 + +**VERIFIED.** The reviewable claim before this was that the binder had never run through the +surface that ships it: the crate had tests, and the catalogue side had live coverage, but no +run had gone request-to-report through the consumer. This one does. + +**Procedure.** `cargo build --bin bridge_mcp` on the branch under review, then the binary driven +over stdio with a real MCP session — `initialize`, `notifications/initialized`, +`tools/call validate_masters` — against `http://127.0.0.1:9001`, TallyPrime 7.1, licence tier +**silver**, `education_mode=false`. The tool read this company's ledger catalogue over the wire +(89,576 bytes, `evidence.state=complete`) and returned the binder's report. Ten fabricated source +names, chosen so that every decision the binder can reach is reached against **live-read master +names**. + +| requested (fabricated) | returned | what it establishes | +| --- | --- | --- | +| `MB PILOT ALPHA (5550001001)` | `exact` | byte equality | +| `mb pilot alpha (5550001001)` | `identifier` | the identifier is consulted **before** the name, so a case variant never reached the fold | +| `MB-PILOT-ALPHA-(5550001001)` | `normalized` | the separator fold — see the caveat below | +| `Alpha Pilot Account 5550001001` | `identifier` | **the rule this module exists for**: a name sharing no word with the master bound on its embedded number | +| `Zeta Holdings 5550001009` | `near_miss`, 2 candidates, `shared_identifier` | one identifier on two masters refuses and shows both | +| `MB TRADING COMPANY LTD` | `near_miss`, 2 candidates | a truncation surfaces both neighbours and chooses neither | +| `MB EXPENSES FY2025` | `near_miss`, 2 candidates, `shared_token` | a shared period label did **not** become an identifier | +| `MB ITEM PH-01-AB-00` | `identifier` | a code matched across punctuation | +| `Zeta Nowhere Traders` | `missing`, `listing: "none"` | an absence stated as an absence, not as an empty list | +| `MB PARTY BETA` | `near_miss`, 4 candidates | a prefix family surfaced whole | + +**`exact_live_spelling` appeared on bound rows only** — `exact`, `identifier`, `normalized` — +and on no refusal. The same ten names sent to the **previously installed** server, same company +and instance minutes earlier, returned `exact_live_spelling` alongside `match_state: "near_miss"` +for two of them. That field on a refusal is a guess wearing the shape of an answer, and it is +what this change deletes; the two runs are the before and after on one real instance. + +**What this slice does not establish.** One company, one instance, one Tally build, one licence +tier. Every *source* name is fabricated — a real source document has still never been bound, so +nothing here speaks to how operator-written names actually differ from master names. It exercises +the MCP consumer; the desktop consumer shares the crate but was not driven. + +**It also reproduced a known gap, live.** The third row bound `MB-PILOT-ALPHA-(5550001001)` to a +master carrying spaces. That is the **reverse** of the direction +`TALLY_PROTOCOL_REFERENCE.md` §9.4b measured, and §9.4b marks it **UNVERIFIED**. The bind is a +Bridge policy about which master an operator meant, not a Tally behaviour, and §9.4b's own remedy +is that a looser fold may *suggest* rather than resolve. Recorded here because the slice is where +it became visible on a real instance rather than in a unit test. + **What it found within minutes.** The `DELTA`/`EPSILON` pair exposed a defect no fabricated fixture had produced: a *byte-exact* request for `MB PARTY DELTA (5550001009)` was being refused as `IdentifierConflict`, because the number in its name is shared. That made the diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 318865e4..1f61b500 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "f7121a349572ceae0ea6c13c32d6e62dc0f18a3b4aa232e34175074212eef8eb", + "compatibility_surface_sha256": "1e948a51f31e93e89ee1f8e86d66fdc879aaa4025a8dccdbc0abfda0f4fafc6c", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 914520b3..eae59f0a 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "2b870cd36175ff236f5a3fc9e6d13728e0b8e6f309c263b6739d56adbff0db20" + "sha256": "b6f717187c0a7a4dcdcc1a9f7a5885f5227979e6f09c212780f34a79e9015293" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "f7121a349572ceae0ea6c13c32d6e62dc0f18a3b4aa232e34175074212eef8eb" + "manifest_sha256": "1e948a51f31e93e89ee1f8e86d66fdc879aaa4025a8dccdbc0abfda0f4fafc6c" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 7998a126..60e81447 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -396,7 +396,16 @@ pub struct BindingTotals { } /// The result of one binding run. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +/// +/// Serializes but does **not** deserialize, and the asymmetry is the point. +/// `assign_fallback` proves provenance by comparing the catalog fingerprint it +/// recorded, and `catalog.fingerprint()` is public — so a derived `Deserialize` +/// would let any caller restore an invented `Ambiguous` entity carrying the +/// right fingerprint and draw a fallback for an entity the binder never emitted. +/// `bind` is the only way to obtain one. A report that must cross a process +/// boundary needs a restore that re-derives these fields, not a derive that +/// trusts them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct BindingReport { class: MasterClass, catalog: CatalogFingerprint, @@ -595,7 +604,18 @@ impl SourceEntity { ) -> Result { let name = validated_name(name)?; let mut identifiers = extract_identifiers(&name)?; - for hint in hints { + for (index, hint) in hints.into_iter().enumerate() { + // Bounded here rather than after the loop: the iterator is + // caller-supplied and may be unbounded, and each turn scans a + // string and allocates. A million repeated hints deduplicate to one + // identifier, so the check below never fired while the work to + // reach it was already done. Every hint yields at least one + // identifier or is refused outright, so more hints than the + // identifier bound cannot produce a usable entity however they fold + // together — the eager bound rejects nothing the late one admitted. + if index >= MAX_IDENTIFIERS_PER_NAME { + return Err(MasterBindingError::TooManyIdentifiers); + } // A hint is caller-supplied document text like any other name, and // must clear the same bound before anything scans or copies it. validate_name_bounds(hint)?; @@ -1170,21 +1190,30 @@ pub(crate) fn comparison_key(value: &str) -> String { /// Whether Tally itself would consider two master names the same. /// /// This is not `comparison_key`, and the difference is not cosmetic. -/// `IMPLEMENTATION_GUIDE.md` §3.3b measured Tally's own master-name matching: -/// case-insensitive **and separator-insensitive — a hyphen matches a space** — -/// and otherwise exact on letters. `BRIDGE PROBE LEDGER A` matched a live -/// `BRIDGE-PROBE-LEDGER-A`; `AND` for `&`, a missing suffix word and a singular -/// for a plural were all rejected. +/// `TALLY_PROTOCOL_REFERENCE.md` §9.4b measured Tally's own master-name +/// matching and verified exactly three transformations: ASCII case folding, one +/// trailing space, and a **space supplied where the master carries a hyphen** +/// (`BRIDGE PROBE LEDGER A` matched a live `BRIDGE-PROBE-LEDGER-A`). `AND` for +/// `&`, a missing suffix word and a singular for a plural were all rejected. +/// +/// **This fold is wider than those three, deliberately, and the width is +/// Bridge's own policy.** The reverse hyphen direction, collapsed whitespace +/// runs, leading whitespace and the Unicode dash variants are all on §9.4b's +/// UNVERIFIED list. It is not "what Tally does" — do not describe it that way. +/// What justifies it is a different question: a bind answers which master the +/// operator *meant*, and two spellings differing only in separators are one +/// name to whoever typed either. `X - Y` is a common ledger convention — six of +/// seventeen hyphenated names in the observed books take that shape — so a +/// binder refusing them invents work rather than preventing an error. /// -/// Tally is the authority on what counts as the same master, so this fold -/// follows it. Being *stricter* than the authority is not the safe direction it -/// looks like: it refuses names Tally would accept, and `X - Y` is a common -/// ledger convention — six of seventeen hyphenated names in the observed books -/// take that shape. +/// Two guards make the width survivable, and neither may be removed without +/// narrowing the fold with it: a fold that merges two **live** masters never +/// resolves, and the write gate admits an exact spelling only. ADR 0016 §3 +/// records the open trade. /// /// It is a **separate** function rather than a widening of `comparison_key` /// precisely because that one is shared: voucher numbers and voucher-type names -/// fold through it too, and §3.3b says nothing about those. One fold per notion +/// fold through it too, and §9.4b says nothing about those. One fold per notion /// of sameness, each named for the question it answers. fn master_identity_key(value: &str) -> String { comparison_key(value) @@ -1239,7 +1268,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro if token.is_empty() { continue; } - let masked_here = is_mask_punctuated(token); + let masked_here = is_mask_punctuated(token) || is_mask_alphabetic(token); let masked = masked_here || previous_was_mask; previous_was_mask = masked_here; let canonical = token @@ -1249,9 +1278,22 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro .collect::(); let digits = canonical.chars().filter(char::is_ascii_digit).count(); let letters = canonical.chars().filter(char::is_ascii_alphabetic).count(); + // Canonicalization keeps only ASCII, so a name written in another + // script and fused to an ASCII suffix yields a code the name never + // contained: a Devanagari party name followed by `AB12345678` + // canonicalized to `AB12345678` and reached an unrelated + // `Bank AB12345678`, while the ASCII-spelled `PartyAB12345678` did not. + // A token identifies by its whole shape or not at all, and that rule + // has to hold in every script or the boundary is an ASCII boundary + // wearing a general name. + let foreign_letters = token + .chars() + .any(|character| !character.is_ascii() && character.is_alphabetic()); if canonical.len() >= MIN_CODE_IDENTIFIER_CHARS && digits >= MIN_CODE_IDENTIFIER_DIGITS && letters >= 2 + && !foreign_letters + && !masked && !is_period(token) && !is_masked(&canonical) { @@ -1302,6 +1344,28 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro /// code branch only, because a mask spelled with letters is caught by the /// letter test; a mask spelled with punctuation reaches the numeric branch, /// where every non-digit is an ordinary delimiter. +/// A mask spelled with letters hides its digits exactly as one spelled with +/// punctuation does. `is_masked` already reads the shape inside a single token, +/// so `XXXX1234` never became a code; written a space apart as `XXXX 12345678` +/// the same statement lost its mask, and the visible suffix escaped as though +/// it were a whole account number — enough to bind a missing purchase ledger to +/// a sole live sales one carrying the same last eight. +/// +/// Two characters minimum, and letters only: a lone `A` is an ordinary name +/// word, and a token carrying digits is already judged whole by `is_masked`. +fn is_mask_alphabetic(token: &str) -> bool { + let canonical = token + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .map(|character| character.to_ascii_uppercase()) + .collect::(); + canonical.len() >= 2 + && canonical + .chars() + .all(|character| character.is_ascii_alphabetic()) + && is_masked(&canonical) +} + fn is_mask_punctuated(token: &str) -> bool { token .chars() @@ -1383,6 +1447,22 @@ fn part_reads_as_period(canonical: &str, any_number: &mut bool) -> bool { let reads_as_period = match run.len() { 1 | 2 => (1..=99).contains(&value), 4 => (1900..=2199).contains(&value), + // A range written without its separator. `FY2025-26` splits and is + // read part by part; `FY202425` arrives whole, and every length + // test above missed it — so the token passed as a code and bound a + // missing `Purchases FY202425` to a sole live `Sales FY202425`. + // Both spellings of the suffix occur: `202425` and `20242025`. + 6 | 8 => { + let (lead, suffix) = run.split_at(4); + let lead = lead.parse::().unwrap_or(u32::MAX); + let suffix_value = suffix.parse::().unwrap_or(u32::MAX); + (1900..=2199).contains(&lead) + && if suffix.len() == 2 { + (1..=99).contains(&suffix_value) + } else { + (1900..=2199).contains(&suffix_value) + } + } _ => false, }; if !reads_as_period { @@ -1393,7 +1473,7 @@ fn part_reads_as_period(canonical: &str, any_number: &mut bool) -> bool { true } -/// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project +/// An eight-digit run that reads as a calendar date in any order this project /// admits is a date, not an identifier. Recognizing only `YYYYMMDD` left /// `01012026` binding a source to an unrelated master that shares its period /// label. Being generous here can only make a bind *less* likely, which is the diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 5b473693..ccb6bb1b 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -569,6 +569,12 @@ fn a_fiscal_period_label_is_not_an_identity_bearing_code() { "2025\u{2013}2026", "2025-2026", "APR2025-MAR2026", + // Written without the separator, the range arrives as one run that + // every length test above missed, and the token passed as a code. + "FY202425", + "FY20242025", + "AY202526", + "202425FY", ] { assert!( entity(&format!("Purchases {label}")) @@ -577,15 +583,56 @@ fn a_fiscal_period_label_is_not_an_identity_bearing_code() { "{label} was treated as a code identifier" ); } - let catalog = ledgers(&["Sales FY2025", "Beta Supply"]); - let binding = bind_one_name(&catalog, "Purchases FY2025"); + for label in ["FY2025", "FY202425"] { + let catalog = ledgers(&[&format!("Sales {label}"), "Beta Supply"]); + let binding = bind_one_name(&catalog, &format!("Purchases {label}")); + assert_eq!( + binding.bound_name(), + None, + "the shared period label {label} bound two unrelated ledgers" + ); + } + // A genuine identity-bearing code still is one. + assert_eq!(entity("Item PH01AB00").identifiers().len(), 1); +} + +#[test] +fn a_name_in_another_script_does_not_shed_its_letters_into_a_code() { + // Canonicalization keeps only ASCII, so a Devanagari party name fused to an + // ASCII suffix yielded the code `AB12345678` — a string the name never + // contained — and reached an unrelated bank ledger. The ASCII spelling of + // the same shape never did, which is what makes it a defect rather than a + // policy: the boundary was an ASCII boundary wearing a general name. + let party = "\u{92a}\u{93e}\u{930}\u{94d}\u{91f}\u{940}"; + let fused = format!("{party}AB12345678"); + assert!( + entity(&fused).identifiers().is_empty(), + "a dropped non-ASCII prefix manufactured a code" + ); + let catalog = ledgers(&["Bank AB12345678", "Beta Supply"]); assert_eq!( - binding.bound_name(), + bind_one_name(&catalog, &fused).bound_name(), None, - "a shared period label must not bind two unrelated ledgers" + "a party name must not reach a bank ledger by shedding its script" + ); + // The ASCII spelling this is measured against, unchanged: it keeps every + // letter, so it carries a code of its own and reaches no bank. + assert_eq!( + entity("PartyAB12345678").identifiers(), + [Identifier { + kind: IdentifierKind::Code, + value: "PARTYAB12345678".to_string(), + }] + ); + assert_eq!( + bind_one_name(&catalog, "PartyAB12345678").bound_name(), + None + ); + // A code standing on its own beside a name in any script is still a code. + assert_eq!( + entity(&format!("{party} AB12345678")).identifiers().len(), + 1 ); - // A genuine identity-bearing code still is one. - assert_eq!(entity("Item PH01AB00").identifiers().len(), 1); } #[test] @@ -756,6 +803,32 @@ fn a_masked_value_identifies_nothing() { "{separated} exposed its suffix as an identifier" ); } + // A mask spelled with letters is the same statement as one spelled with + // punctuation, and it too is written apart from the digits it hides. Read + // token by token, `XXXX` failed the punctuation test and the visible suffix + // escaped as a whole account number. + for separated in [ + "Purchases XXXX 12345678", + "Purchases XXXXXXXX 12345678", + "Purchases (XXXX) 12345678", + ] { + assert!( + entity(separated).identifiers().is_empty(), + "{separated} exposed its suffix as an identifier" + ); + } + let alphabetic = ledgers(&["Sales XXXX 12345678", "Beta Supply"]); + assert_eq!( + bind_one_name(&alphabetic, "Purchases XXXX 12345678").bound_name(), + None, + "a masked last-eight must not bind two unrelated ledgers" + ); + // A suffix shaped as a code is no less hidden than one shaped as a number. + assert!(entity("Purchases XXXX AB12345678").identifiers().is_empty()); + // Ordinary words are not masks, however repetitive: only a run of one + // repeated letter is, and one letter alone is an ordinary word. + assert_eq!(entity("Purchases Unit 5550001001").identifiers().len(), 1); + assert_eq!(entity("Purchases A 5550001001").identifiers().len(), 1); // Ordinary punctuation around a whole number is not a mask. assert_eq!(entity("Party (5550001001)").identifiers().len(), 1); assert_eq!(entity("Party 5550001-002").identifiers().len(), 1); @@ -977,6 +1050,24 @@ fn an_identifier_hint_is_bounded_before_anything_scans_it() { SourceEntity::with_identifier_hints(0, "Alpha Traders", [huge.as_str()]), Err(MasterBindingError::NameTooLong) ); + // Bounding each hint does not bound the iterator. Repeated hints fold to + // one identifier, so the deduplicated check never fired however many + // arrived, while every one of them was scanned and copied first. + let repeated = vec!["5550001001"; MAX_IDENTIFIERS_PER_NAME + 1]; + assert_eq!( + SourceEntity::with_identifier_hints(0, "Alpha Traders", repeated), + Err(MasterBindingError::TooManyIdentifiers) + ); + // The bound admits everything a usable entity could carry. + let distinct = (0..MAX_IDENTIFIERS_PER_NAME) + .map(|index| format!("555000{index:04}")) + .collect::>(); + assert!(SourceEntity::with_identifier_hints( + 0, + "Alpha Traders", + distinct.iter().map(String::as_str) + ) + .is_ok()); } #[test] From 81897391facec7ba58c86a28716407dd156247a6 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 11 Sep 2026 14:06:16 +0530 Subject: [PATCH 26/75] fix(master-binding): only a measured fold may resolve a master name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A seventh review finding, and the one I had raised upstream myself an hour earlier from the other direction: the fold that decided a binding resolved on four transformations TALLY_PROTOCOL_REFERENCE.md §9.4b marks UNVERIFIED — the reverse hyphen direction, collapsed whitespace runs, leading whitespace, and the Unicode dash variants. A module whose first principle is that a near-miss never auto-resolves was auto-resolving on evidence that does not support resolving. §9.4b's own remedy is that a looser fold may *suggest* while only the measured transformations settle anything, so there are now two folds: - `verified_fold` resolves, and implements only the three §9.4b measured: ASCII case, one trailing space, and a source space matching a master hyphen. - `master_identity_key` suggests, keeps every unverified transformation, and everything it reaches is offered as a `NormalizedEqual` candidate. Direction needed the index, not a narrower string function. The measurement is a source *space* against a master *hyphen*, and a shared key is symmetric. So a master answers to its own spelling and to its hyphens-as-spaces, while a source answers only to its own: a source hyphen finds no master space, a source space still finds a master hyphen. Two masters answering to one key remain an ambiguity, which is what Tally's behaviour implies. The cost is real and is now written down rather than discovered later. `X - Y` is a common ledger convention — six of seventeen hyphenated names in the observed books — and reaching it from `X Y` needs the measured hyphen step *and* a whitespace run collapsed, so it no longer resolves. On the fabricated mutation book 420 of 995 mutations bind where most once did. What makes that a trade and not a loss is asserted case by case rather than as a percentage: every mutation the wide fold would have resolved is still shown as a candidate carrying the right master. Of the 278 that reach neither, the wide fold would have bound zero — they were already out of reach. Six tests fail if the narrow index is reverted. ADR 0016 §3 is rewritten around the two folds and records the cost. The live slice in TEST_CORPUS.md §9 was run before this change; its third row showed the old bind and is marked PENDING a re-run, the other nine being unaffected. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 81 +++++---- docs/tally/TEST_CORPUS.md | 26 ++- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 100 ++++++++--- .../src/master_binding_tests.rs | 156 ++++++++++++++---- src-tauri/src/agent_import_tests.rs | 39 +++-- 7 files changed, 291 insertions(+), 117 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index b2febc07..2660dbe4 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -184,38 +184,57 @@ favour. under **Tally's own rule for when two master names are the same**, and only when exactly one master shares it. -**Part of that rule is measured, and the part that is not has to say so.** +**There are two folds, and which one may answer is the whole of this section.** + `TALLY_PROTOCOL_REFERENCE.md` §9.4b sent named variants at a live master and -recorded which Tally accepted: ASCII case folding, one trailing space, and a -**space supplied where the master carries a hyphen**. `AND` for `&`, a missing -suffix word and a singular for a plural were rejected. Those three are Tally's -behaviour; §9.4b marks everything else **UNVERIFIED** and warns that a fold is -only as safe as its least-verified step. - -This fold is wider. It also folds the reverse hyphen direction, collapses runs -of internal whitespace, ignores leading whitespace, and folds Unicode dash and -quote variants to ASCII — four transformations on §9.4b's unverified list. An -earlier draft of this section claimed the fold "stops exactly where Tally -stops". That was wrong, and the live slice in `TEST_CORPUS.md` §9 shows it -binding on the unverified reverse direction against a real instance. - -**So the extra width is Bridge's policy, not Tally's, and stands on its own -argument:** a bind answers *which master the operator meant*, and two spellings -differing only in separators are one name to whoever typed either. Two guards -carry that. A fold merging two **live** masters never resolves — the pair is an -ambiguity and both surface (§4). And the write gate admits `exact` only, so a -normalized bind informs an operator without widening what may be written. - -It is nevertheless the least-proven step in this module. §9.4b's own remedy is -open — let the verified three resolve and a looser fold only *suggest* — and -taking it would reinstate the refusals the next paragraph argues against. That -trade is recorded here, not settled here. - -**Being stricter than the authority is not the safe direction it appears to -be.** It refuses names Tally would accept, and `X - Y` is a common ledger -convention — six of the seventeen hyphenated names in the observed books take -that shape. A binder that reports a near-miss for a name the book would have -matched has invented work, not prevented an error. +recorded which Tally accepted. Exactly three: ASCII case folding, one trailing +space, and a **space supplied where the master carries a hyphen**. `AND` for +`&`, a missing suffix word and a singular for a plural were rejected. §9.4b +marks everything else UNVERIFIED and states the rule this section now follows — +*a fold is only as safe as its least-verified step, and a looser fold may +**suggest**, never resolve.* + +- The **narrow fold** resolves. It implements those three and nothing else. The + hyphen step is directional, because the measurement was: a source **space** + was sent at a master **hyphen**, and the reverse was never sent. A symmetric + key cannot express a direction, so the master side of the index answers to + both its own spelling and its hyphens-as-spaces, while the source side answers + only to its own. A source hyphen therefore finds no master space. +- The **wide fold** suggests. It carries the reverse hyphen direction, collapsed + whitespace runs, leading whitespace and the Unicode dash variants — and + everything it reaches is offered as a `NormalizedEqual` candidate for a human + to confirm. + +**Trimming a source name is not part of either fold.** `SourceEntity` trims +what the document gave it, at the boundary, because leading and trailing space +in extracted text is transcription noise; an observed master name is retained +byte for byte, because a caller writes it back. So a source reading +`" Alpha Traders"` reaches `Alpha Traders`, while a *master* spelled +`" Alpha Traders"` does not resolve from a clean source name — it is offered. +The asymmetry is deliberate and is the P3 rule, not a claim about what Tally +folds. + +**This was got wrong first, and the correction is the useful record.** An +earlier version of this ADR claimed the fold "stops exactly where Tally stops" +while the implementation resolved on four transformations §9.4b marks +UNVERIFIED. It read naturally, which is exactly the skimming-implementer failure +§9.4b was written to prevent, and the live slice in `TEST_CORPUS.md` §9 caught +it binding that way against a real instance. + +**The cost is real and is stated here rather than discovered later.** `X - Y` is +a common ledger convention — six of the seventeen hyphenated names in the +observed books take that shape — and reaching it from `X Y` needs the measured +hyphen step *and* a whitespace run collapsed. So those no longer resolve. On the +fabricated mutation book, 420 of 995 mutations bind where most once did. + +**What makes that a trade and not a loss** is measured alongside it: every +mutation the wide fold would have resolved is still shown, as a candidate +carrying the right master. The sweep asserts it case by case rather than as a +percentage. So narrowing the fold costs a confirmation, never a search — which +is the trade §9.4b prescribes and the same one §4 makes for every other +near-miss in this module. A binder that answers from unverified evidence has not +saved the operator a step; it has moved the step to wherever the wrong posting +is found. This fold is deliberately **separate from the general comparison key**, which is shared with other contracts for voucher numbers and voucher-type names. §9.4b diff --git a/docs/tally/TEST_CORPUS.md b/docs/tally/TEST_CORPUS.md index 346152f5..8c97287f 100644 --- a/docs/tally/TEST_CORPUS.md +++ b/docs/tally/TEST_CORPUS.md @@ -359,7 +359,7 @@ them is **PARTIAL**. Scope of each, so neither is read for more than it covers: | The ten ledgers exist in that company and nowhere else | **VERIFIED** | `CREATED=10, ALTERED=0, ERRORS=0`, then a readback of the ledger list naming all ten, plus a readback of a guard company showing none | | No book carried an embedded identifier before this | **VERIFIED** | all 16 loaded companies read through the `StandardLedgerCatalogV1` request, responses written to files and parsed from the files; 470 names, 0 numeric and 1 code identifier | | The identifier rule behaves correctly against live-read names | **PARTIAL** | exercised against these ten seeded names only, on one instance, one licence tier, one Tally build. Fabricated *source* names against live *catalogue* names — no real source document has been bound end to end | -| The shipped consumer path runs end to end against a real instance | **VERIFIED 2026-09-11** | the branch's own `bridge_mcp` binary driven over stdio against licensed TallyPrime 7.1 Silver, reading this company's catalogue over the wire and returning the binder's report — see "The end-to-end slice" below | +| The shipped consumer path runs end to end against a real instance | **VERIFIED 2026-09-11**, nine rows of ten | the branch's own `bridge_mcp` binary driven over stdio against licensed TallyPrime 7.1 Silver, reading this company's catalogue over the wire and returning the binder's report. One row is **PENDING** a re-run after the fold was narrowed — see "The end-to-end slice" below | | Binding is safe on catalogues generally | **UNVERIFIED** | one company, one instance, one Tally build, and every *source* name fabricated. No engagement has run a real document through this path; the mutation sweep is fabricated mutations of live names, not observed operator input | **Added 2026-09-10.** Ten ledgers prefixed `MB `, seeded so the master-binding @@ -403,7 +403,7 @@ silent `Alter` that overwrites. surface that ships it: the crate had tests, and the catalogue side had live coverage, but no run had gone request-to-report through the consumer. This one does. -**Procedure.** `cargo build --bin bridge_mcp` on the branch under review, then the binary driven +**Procedure.** `cargo build --bin bridge_mcp` at commit `9e34cd77`, then the binary driven over stdio with a real MCP session — `initialize`, `notifications/initialized`, `tools/call validate_masters` — against `http://127.0.0.1:9001`, TallyPrime 7.1, licence tier **silver**, `education_mode=false`. The tool read this company's ledger catalogue over the wire @@ -415,7 +415,7 @@ names**. | --- | --- | --- | | `MB PILOT ALPHA (5550001001)` | `exact` | byte equality | | `mb pilot alpha (5550001001)` | `identifier` | the identifier is consulted **before** the name, so a case variant never reached the fold | -| `MB-PILOT-ALPHA-(5550001001)` | `normalized` | the separator fold — see the caveat below | +| `MB-PILOT-ALPHA-(5550001001)` | `normalized` | the separator fold — **since narrowed**, see below | | `Alpha Pilot Account 5550001001` | `identifier` | **the rule this module exists for**: a name sharing no word with the master bound on its embedded number | | `Zeta Holdings 5550001009` | `near_miss`, 2 candidates, `shared_identifier` | one identifier on two masters refuses and shows both | | `MB TRADING COMPANY LTD` | `near_miss`, 2 candidates | a truncation surfaces both neighbours and chooses neither | @@ -435,12 +435,20 @@ tier. Every *source* name is fabricated — a real source document has still nev nothing here speaks to how operator-written names actually differ from master names. It exercises the MCP consumer; the desktop consumer shares the crate but was not driven. -**It also reproduced a known gap, live.** The third row bound `MB-PILOT-ALPHA-(5550001001)` to a -master carrying spaces. That is the **reverse** of the direction -`TALLY_PROTOCOL_REFERENCE.md` §9.4b measured, and §9.4b marks it **UNVERIFIED**. The bind is a -Bridge policy about which master an operator meant, not a Tally behaviour, and §9.4b's own remedy -is that a looser fold may *suggest* rather than resolve. Recorded here because the slice is where -it became visible on a real instance rather than in a unit test. +**It found a defect, which is the reason to run these.** The third row bound +`MB-PILOT-ALPHA-(5550001001)` to a master carrying spaces. That is the **reverse** of the +direction `TALLY_PROTOCOL_REFERENCE.md` §9.4b measured, and §9.4b marks it **UNVERIFIED** — so +the binder was resolving on evidence that does not support resolving, which is exactly what +§9.4b exists to prevent. The resolving fold has since been narrowed to the three verified +transformations, and that row now returns a near-miss carrying `MB PILOT ALPHA (5550001001)` as +its sole candidate. ADR 0016 §3 records the narrowing and its cost. + +**One row of this table is therefore owed a re-run.** The nine other rows are unaffected by the +narrowing — they turn on identifiers, exact equality or refusal — and their unit coverage is +unchanged. Row three's new behaviour is covered by +`a_source_space_matches_a_master_hyphen_and_only_that_direction` but has not itself been seen on +a live instance: the lab endpoint went down before the re-run. Read this table as VERIFIED for +nine rows and PENDING for one, not as ten. **What it found within minutes.** The `DELTA`/`EPSILON` pair exposed a defect no fabricated fixture had produced: a *byte-exact* request for `MB PARTY DELTA (5550001009)` was being diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 1f61b500..a76ba352 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "1e948a51f31e93e89ee1f8e86d66fdc879aaa4025a8dccdbc0abfda0f4fafc6c", + "compatibility_surface_sha256": "7d7858fd1fdc78fe2cf244a47f02e3408ab312fedf98a9ef91d5efa6189365a8", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index eae59f0a..d9cb90e5 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "b6f717187c0a7a4dcdcc1a9f7a5885f5227979e6f09c212780f34a79e9015293" + "sha256": "07105265ea8a0cc424f296b1a1044241ba68d6ce4fe6876a7d788e6deeb7656d" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "1e948a51f31e93e89ee1f8e86d66fdc879aaa4025a8dccdbc0abfda0f4fafc6c" + "manifest_sha256": "7d7858fd1fdc78fe2cf244a47f02e3408ab312fedf98a9ef91d5efa6189365a8" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 60e81447..ccfa9763 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -584,7 +584,10 @@ impl FallbackBinding { pub struct SourceEntity { position: usize, name: String, + /// The wide fold. Suggests; never resolves. key: String, + /// The narrow fold. Resolves. + binding_key: String, identifiers: Vec, } @@ -633,6 +636,7 @@ impl SourceEntity { Ok(Self { position, key: master_identity_key(&name), + binding_key: verified_fold(&name), name, identifiers, }) @@ -678,6 +682,7 @@ pub struct MasterCatalog { entries: Vec, by_name: BTreeMap, by_key: BTreeMap>, + by_binding_key: BTreeMap>, by_identifier: BTreeMap>, by_token: BTreeMap>, common_tokens: BTreeSet, @@ -717,10 +722,14 @@ impl MasterCatalog { } let mut by_key: BTreeMap> = BTreeMap::new(); + let mut by_binding_key: BTreeMap> = BTreeMap::new(); let mut by_identifier: BTreeMap> = BTreeMap::new(); let mut by_token: BTreeMap> = BTreeMap::new(); for (index, entry) in entries.iter().enumerate() { by_key.entry(entry.key.clone()).or_default().push(index); + for binding_key in master_binding_keys(&entry.name) { + by_binding_key.entry(binding_key).or_default().push(index); + } for identifier in &entry.identifiers { by_identifier .entry(identifier.clone()) @@ -765,6 +774,7 @@ impl MasterCatalog { entries, by_name, by_key, + by_binding_key, by_identifier, by_token, common_tokens, @@ -909,7 +919,15 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) basis: BindingBasis::Identifier, } } else { - match catalog.by_key.get(&entity.key).map(Vec::as_slice) { + // The narrow index, not the wide one: only a transformation Tally was + // measured performing may settle which master was meant. Everything the + // wide fold reaches and this does not falls through to `collect_candidates` + // below, where it is offered as `NormalizedEqual` for a human to confirm. + match catalog + .by_binding_key + .get(&entity.binding_key) + .map(Vec::as_slice) + { Some([index]) => BindingStatus::Bound { catalog_name: catalog.entries[*index].name.clone(), basis: BindingBasis::NormalizedName, @@ -1187,34 +1205,30 @@ pub(crate) fn comparison_key(value: &str) -> String { .join(" ") } -/// Whether Tally itself would consider two master names the same. -/// -/// This is not `comparison_key`, and the difference is not cosmetic. -/// `TALLY_PROTOCOL_REFERENCE.md` §9.4b measured Tally's own master-name -/// matching and verified exactly three transformations: ASCII case folding, one -/// trailing space, and a **space supplied where the master carries a hyphen** -/// (`BRIDGE PROBE LEDGER A` matched a live `BRIDGE-PROBE-LEDGER-A`). `AND` for -/// `&`, a missing suffix word and a singular for a plural were all rejected. +/// The **wide** fold: which masters are worth showing a human. /// -/// **This fold is wider than those three, deliberately, and the width is -/// Bridge's own policy.** The reverse hyphen direction, collapsed whitespace -/// runs, leading whitespace and the Unicode dash variants are all on §9.4b's -/// UNVERIFIED list. It is not "what Tally does" — do not describe it that way. -/// What justifies it is a different question: a bind answers which master the -/// operator *meant*, and two spellings differing only in separators are one -/// name to whoever typed either. `X - Y` is a common ledger convention — six of -/// seventeen hyphenated names in the observed books take that shape — so a -/// binder refusing them invents work rather than preventing an error. +/// This is deliberately looser than anything measured, and it may never decide +/// a binding. `verified_fold` does that. The separation is the whole design: +/// §9.4b verified three transformations and marks the rest UNVERIFIED, and its +/// own remedy is that a looser fold may *suggest* while only the measured ones +/// resolve. So the reverse hyphen direction, collapsed whitespace runs, leading +/// whitespace and the Unicode dash variants all live here, where the worst they +/// can do is put the right master in front of an operator. /// -/// Two guards make the width survivable, and neither may be removed without -/// narrowing the fold with it: a fold that merges two **live** masters never -/// resolves, and the write gate admits an exact spelling only. ADR 0016 §3 -/// records the open trade. +/// An earlier version of this module let this fold bind. It read naturally and +/// was wrong: `X - Y` is a common ledger convention — six of seventeen +/// hyphenated names in the observed books take that shape — and reaching it +/// from `X Y` needs the measured hyphen step **and** a whitespace run collapsed, +/// which nothing measured. Answering from here was a Bridge guess wearing +/// Tally's authority, and `TEST_CORPUS.md` §9 caught it binding that way +/// against a live instance. /// /// It is a **separate** function rather than a widening of `comparison_key` /// precisely because that one is shared: voucher numbers and voucher-type names /// fold through it too, and §9.4b says nothing about those. One fold per notion -/// of sameness, each named for the question it answers. +/// of sameness, each named for the question it answers — and here there are two +/// notions, because "could this be the master?" and "is this the master?" are +/// different questions with different evidence behind them. fn master_identity_key(value: &str) -> String { comparison_key(value) .replace('-', " ") @@ -1223,6 +1237,46 @@ fn master_identity_key(value: &str) -> String { .join(" ") } +/// The fold that may **resolve** a name to a master, held to exactly what +/// `TALLY_PROTOCOL_REFERENCE.md` §9.4b measured Tally doing. +/// +/// Three transformations were verified: ASCII case folding, one trailing space +/// ignored, and a **space supplied where the master carries a hyphen**. That +/// last one is directional — `BRIDGE PROBE LEDGER A` was sent against a live +/// `BRIDGE-PROBE-LEDGER-A`, and the reverse was never sent — so it cannot be a +/// symmetric replacement in a shared key. It lives in `master_binding_keys`, +/// on the master side only, which is the side the evidence is about. +/// +/// One step here is not on that list: NFC. Canonical equivalence is a property +/// of how the same characters may be encoded, not a claim about which names +/// Tally treats as one, and distinguishing two encodings of an identical name +/// would be distinguishing something no operator can see or type differently. +/// Every other unverified step — the reverse hyphen direction, collapsed +/// whitespace runs, leading whitespace, Unicode dash variants, non-ASCII case — +/// is deliberately absent. They are not lost: `master_identity_key` still +/// carries them, and everything it reaches is offered as a candidate. +fn verified_fold(value: &str) -> String { + // One trailing space, because one is what was sent. + let value = value.strip_suffix(' ').unwrap_or(value); + value.nfc().collect::().to_ascii_lowercase() +} + +/// The keys a **master** name answers to. +/// +/// Its own, and — because a source space was measured matching a master hyphen +/// — the same name with its hyphens read as spaces. Offering the second from +/// the master side is what keeps the measured direction measured: a source +/// hyphen finds no master space, while a source space finds a master hyphen. +/// +/// Two masters that answer to one key are an ambiguity and are refused there, +/// which is the same answer Tally's own behaviour implies: it would match that +/// source name to both. +fn master_binding_keys(value: &str) -> BTreeSet { + let base = verified_fold(value); + let hyphens_as_spaces = base.replace('-', " "); + BTreeSet::from([base, hyphens_as_spaces]) +} + /// Splits a comparison key into words. /// /// The separator set is defined **positively** — whitespace, and ASCII diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index ccb6bb1b..93fb424c 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -88,11 +88,18 @@ fn an_observed_master_name_is_retained_verbatim_while_a_source_name_is_trimmed() // A caller writes the bound name back to Tally byte for byte. Trimming an // observed name here would report a spelling that does not exist and // refuse at the write gate with no explanation. - let catalog = ledgers(&[" Alpha Traders ", "Beta Supply"]); - assert_eq!(catalog.names().next(), Some(" Alpha Traders ")); + let catalog = ledgers(&["Alpha Traders ", "Beta Supply"]); + assert_eq!(catalog.names().next(), Some("Alpha Traders ")); let binding = bind_one_name(&catalog, "Alpha Traders"); - assert_eq!(binding.bound_name(), Some(" Alpha Traders ")); + assert_eq!(binding.bound_name(), Some("Alpha Traders ")); assert_eq!(binding.source_name, "Alpha Traders"); + // One trailing space is what §9.4b sent. *Leading* whitespace is on its + // unverified list, so a master carrying it surfaces as a candidate instead + // of resolving — and is retained verbatim either way. + let leading = ledgers(&[" Alpha Traders", "Beta Supply"]); + let binding = bind_one_name(&leading, "Alpha Traders"); + assert_eq!(binding.bound_name(), None); + assert_eq!(candidate_names(&binding), [" Alpha Traders"]); } #[test] @@ -152,16 +159,32 @@ fn an_exact_name_binds() { } #[test] -fn case_whitespace_and_dash_style_do_not_defeat_a_bind() { - let catalog = ledgers(&["Alpha \u{2013} Traders", "Beta Supply"]); - let binding = bind_one_name(&catalog, " alpha - TRADERS "); +fn case_binds_but_an_unverified_fold_only_suggests() { + // ASCII case folding is measured, so it resolves. + let cased = ledgers(&["Alpha Traders", "Beta Supply"]); assert_eq!( - binding.status, + bind_one_name(&cased, "ALPHA traders").status, BindingStatus::Bound { - catalog_name: "Alpha \u{2013} Traders".to_string(), + catalog_name: "Alpha Traders".to_string(), basis: BindingBasis::NormalizedName, } ); + + // An en dash, a collapsed whitespace run and leading whitespace are all on + // §9.4b's unverified list. The wide fold still reaches the master, so it is + // offered — a candidate a human confirms, which is exactly what §9.4b says + // a looser fold is for. Nothing is lost here except the automatic answer. + let catalog = ledgers(&["Alpha \u{2013} Traders", "Beta Supply"]); + let binding = bind_one_name(&catalog, " alpha - TRADERS "); + assert_eq!(binding.bound_name(), None); + assert_eq!(reason(&binding), UnboundReason::NearMiss); + assert_eq!( + binding.unresolved().expect("unbound").candidates.listed(), + [Candidate { + catalog_name: "Alpha \u{2013} Traders".to_string(), + rule: CandidateRule::NormalizedEqual, + }] + ); } #[test] @@ -415,25 +438,36 @@ fn a_decisive_identifier_pointing_elsewhere_still_outranks_a_byte_exact_name() { } #[test] -fn a_hyphen_matches_a_space_because_tally_says_so() { - // IMPLEMENTATION_GUIDE.md §3.3b, measured: Tally's own master-name matching - // treats a hyphen as a space. Being stricter than the authority refuses - // names Tally would accept, and `X - Y` is a common ledger convention. - let catalog = ledgers(&["Bank - HDFC Current", "Beta Supply"]); - let binding = bind_one_name(&catalog, "Bank HDFC Current"); +fn a_source_space_matches_a_master_hyphen_and_only_that_direction() { + // `TALLY_PROTOCOL_REFERENCE.md` §9.4b sent `BRIDGE PROBE LEDGER A` at a live + // `BRIDGE-PROBE-LEDGER-A` and Tally matched it. That is the whole of the + // measurement: one separator, one direction. + let hyphenated = ledgers(&["BRIDGE-PROBE-LEDGER-A", "Beta Supply"]); assert_eq!( - binding.status, + bind_one_name(&hyphenated, "BRIDGE PROBE LEDGER A").status, BindingStatus::Bound { - catalog_name: "Bank - HDFC Current".to_string(), + catalog_name: "BRIDGE-PROBE-LEDGER-A".to_string(), basis: BindingBasis::NormalizedName, } ); - // And the reverse direction. - let hyphenated = ledgers(&["BRIDGE PROBE LEDGER A", "Beta Supply"]); - assert_eq!( - bind_one_name(&hyphenated, "BRIDGE-PROBE-LEDGER-A").bound_name(), - Some("BRIDGE PROBE LEDGER A") - ); + + // The reverse was never sent, and §9.4b marks it UNVERIFIED. A symmetric + // replacement would resolve it, which is why the hyphen fold lives on the + // master side of the index rather than in a key both sides share. + let spaced = ledgers(&["BRIDGE PROBE LEDGER A", "Beta Supply"]); + let binding = bind_one_name(&spaced, "BRIDGE-PROBE-LEDGER-A"); + assert_eq!(binding.bound_name(), None); + assert_eq!(candidate_names(&binding), ["BRIDGE PROBE LEDGER A"]); + + // `X - Y` is a common ledger convention, and reaching it from `X Y` needs + // the measured hyphen step *and* a whitespace run collapsed — which is not + // measured. So it suggests rather than resolves. This is the largest single + // cost of holding the fold to the evidence, and it is recorded here so that + // widening it again is a deliberate act with a test to change. + let spaced_hyphen = ledgers(&["Bank - HDFC Current", "Beta Supply"]); + let binding = bind_one_name(&spaced_hyphen, "Bank HDFC Current"); + assert_eq!(binding.bound_name(), None); + assert_eq!(candidate_names(&binding), ["Bank - HDFC Current"]); } #[test] @@ -456,12 +490,19 @@ fn masters_that_collapse_under_the_fold_are_refused_never_chosen() { // Refuse-ambiguous, never-pick: with no exact spelling to prefer, the // collapse is reported with both masters offered, not resolved to one. - for spelling in ["alpha beta", "ALPHA BETA"] { - let binding = bind_one_name(&catalog, spelling); - assert_eq!(reason(&binding), UnboundReason::NameAmbiguous); - assert_eq!(binding.bound_name(), None); - assert_eq!(candidate_names(&binding), ["Alpha Beta", "Alpha-Beta"]); - } + let binding = bind_one_name(&catalog, "alpha beta"); + assert_eq!(reason(&binding), UnboundReason::NameAmbiguous); + assert_eq!(binding.bound_name(), None); + assert_eq!(candidate_names(&binding), ["Alpha Beta", "Alpha-Beta"]); + + // A whitespace run is not a verified transformation, so this one never + // reaches the narrow index at all. It is still refused, and still shows + // both — a near-miss rather than an ambiguity, which is the honest label: + // these two are not proven to collapse, they are merely both plausible. + let binding = bind_one_name(&catalog, "ALPHA BETA"); + assert_eq!(reason(&binding), UnboundReason::NearMiss); + assert_eq!(binding.bound_name(), None); + assert_eq!(candidate_names(&binding), ["Alpha Beta", "Alpha-Beta"]); } #[test] @@ -1403,11 +1444,15 @@ fn fabricated_document() -> Vec<(&'static str, Option<&'static str>, Expected)> // Named exactly as the book spells it. ("Cash", None, Expected::Bound("Cash")), ("CGST OUTPUT 9%", None, Expected::Bound("CGST OUTPUT 9%")), - // Case and spacing noise from the source system. + // Case noise alone is measured, so it still resolves. + ("cgst output 9%", None, Expected::Bound("CGST OUTPUT 9%")), + // Spacing noise from the source system is not. Leading whitespace and + // a collapsed run are both on §9.4b's unverified list, so this one is + // offered rather than answered — with `CGST OUTPUT 9%` first. ( " cgst output 9% ", None, - Expected::Bound("CGST OUTPUT 9%"), + Expected::Unbound(UnboundReason::NearMiss), ), ( "beta placeholder trading co", @@ -1501,9 +1546,9 @@ fn a_document_against_a_realistic_book_binds_only_where_a_human_would() { // The shape of the answer, pinned so a loosened threshold moves a number. let totals = report.totals(); - assert_eq!(totals.requested, 12); + assert_eq!(totals.requested, 13); assert_eq!(totals.bound, 7); - assert_eq!(totals.ambiguous, 3); + assert_eq!(totals.ambiguous, 4); assert_eq!(totals.unmatched, 2); assert_eq!(totals.requested, totals.bound + totals.unbound); } @@ -1581,8 +1626,19 @@ fn no_mutation_of_a_master_name_ever_binds_to_a_different_master() { .map(|name| (comparison_key(name), name.as_str())) .collect::>(); + // What the *wide* fold would resolve. Narrowing the resolving fold to the + // three transformations §9.4b verified is only defensible if it withdraws + // answers, never masters, so the cases it used to settle are tracked by + // name rather than by a percentage that drifts with the fixture. + let wide = names + .iter() + .map(|name| (master_identity_key(name), name.as_str())) + .collect::>(); + let mut checked = 0_usize; let mut self_bound = 0_usize; + let mut self_offered = 0_usize; + let mut downgraded = 0_usize; for name in &names { for mutation in source_mutations(name) { let key = comparison_key(&mutation); @@ -1591,8 +1647,23 @@ fn no_mutation_of_a_master_name_ever_binds_to_a_different_master() { } checked += 1; let binding = bind_one_name(&catalog, &mutation); + let wide_would_bind = wide.get(&master_identity_key(&mutation)) == Some(&name.as_str()); match binding.bound_name() { - None => {} + None => { + let offered = candidate_names(&binding).iter().any(|shown| shown == name); + if offered { + self_offered += 1; + } + // The invariant that makes the narrowing a trade rather than + // a loss: anything the wide fold settled is still shown. + assert!( + !wide_would_bind || offered, + "narrowing the fold hid {name:?} from its own mutation {mutation:?}" + ); + if wide_would_bind { + downgraded += 1; + } + } Some(bound_to) => { assert_eq!( bound_to, name, @@ -1607,10 +1678,23 @@ fn no_mutation_of_a_master_name_ever_binds_to_a_different_master() { checked > 900, "the sweep must actually cover the book: {checked}" ); - // Most mutations are case and spacing noise, which must still bind. + // The narrowing this book measures. Most of these mutations are spacing + // and dash noise, and holding the resolving fold to the three + // transformations §9.4b actually verified stops most of them resolving. + // That is the intended trade and not the property under test — what has to + // hold is that a withdrawn answer left the right master **visible**, so the + // cost is one confirmation rather than a master a human never sees. + // The cost, stated rather than implied. Most of this book's mutations are + // spacing and dash noise, and holding the resolving fold to the evidence + // stops most of them resolving: they become a near-miss carrying the right + // master, which costs a confirmation and never a search. + assert!( + downgraded > 0, + "the sweep no longer exercises the narrowed fold at all" + ); assert!( - self_bound * 2 > checked, - "only {self_bound} of {checked} mutations bound at all" + self_bound + self_offered > checked * 2 / 3, + "{self_bound} bound and {self_offered} offered of {checked}" ); } diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index f42d5eec..2be4d760 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -275,9 +275,10 @@ fn schema_balance_matcher_rendering_and_ledger_append_are_fail_closed() { validate_payload(&unbalanced), Err("voucher_not_balanced".to_string()) ); - // Case, whitespace style, dash style and quote style name the same live - // ledger, so they bind and report its exact spelling. Only byte equality - // is `exact`, which is what build_import_xml admits. + // ASCII case and one trailing space are transformations + // `TALLY_PROTOCOL_REFERENCE.md` §9.4b measured Tally performing, so they + // name the same live ledger: they bind and report its exact spelling. Only + // byte equality is `exact`, which is what build_import_xml admits. for wanted in ["bank ", "bank", "BANK"] { let matched = one_master_match(wanted, &["Bank"]); assert_eq!(matched["match_state"], "normalized"); @@ -286,18 +287,26 @@ fn schema_balance_matcher_rendering_and_ledger_append_are_fail_closed() { "Bank" ); } - assert_eq!( - one_master_match("A\u{a0}B", &["A B"])["match_state"], - "normalized" - ); - assert_eq!( - one_master_match("Fees-Admin", &["Fees–Admin"])["match_state"], - "normalized" - ); - assert_eq!( - one_master_match("Bob's", &["Bob’s"])["match_state"], - "normalized" - ); + // A non-breaking space, an en dash and a curly quote are **not** on that + // list. Each still reaches its master through the wide fold, so the caller + // sees one candidate and confirms it; what it no longer gets is an answer + // and a live spelling to copy into the exact-only write gate. + for (wanted, live) in [ + ("A\u{a0}B", "A B"), + ("Fees-Admin", "Fees\u{2013}Admin"), + ("Bob's", "Bob\u{2019}s"), + ] { + let matched = one_master_match(wanted, &[live]); + assert_eq!( + matched["match_state"], "near_miss", + "{wanted} resolved on an unverified fold" + ); + assert!(matched.get("exact_live_spelling").is_none()); + assert_eq!( + matched["candidates"][0]["name"][super::super::PARTY_NAME_MARKER], + live + ); + } assert_eq!(one_master_match("Bank", &["Bank"])["match_state"], "exact"); // A shorter name that a live ledger extends is a near-miss with one // candidate, and one candidate is still not a decision. From 54f4c847cec26da609160f5c588aa122effb37d3 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 11 Sep 2026 14:14:34 +0530 Subject: [PATCH 27/75] fix(master-binding): canonical equivalence is measured wrong, not unverified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The narrowing held the resolving fold to §9.4b's three verified transformations but left `.nfc()` in it, and that step is worse than the four this PR already removed: it is not unproven, it is proven wrong. `tally-matches-master-names-by-exact-codepoint` measured it on 2026-08-19. A voucher naming a UI-created NFC ledger in its canonically equivalent NFD spelling was rejected — `EXCEPTIONS=1`, `LINEERROR`, ledger does not exist — while the NFC spelling created it. Tally stores the bytes it was given and matches on exact codepoints, so the two spellings are different masters to Tally, and folding them together resolves a source name onto a master Tally itself keeps apart. NFC stays in the wide fold, where it can only suggest: the NFD source now reaches a near-miss carrying the NFC master as its sole candidate. The reason it survived is worth more than the fix. I audited this fold against §9.4b twice and never counted `.nfc()`, because canonical equivalence reads as *decoding* rather than folding — the same characters, spelled two ways, nothing an operator could type differently on purpose. Every row in §9.4b's table is a judgement step, so an auditor working from the table finds nothing wrong with normalising first. ADR 0016 §3 now says so in general terms: a step that reads like decoding deserves the same evidence as a step that reads like folding. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 22 +++++++++++-- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +-- .../bridge-tally-core/src/master_binding.rs | 20 +++++++----- .../src/master_binding_tests.rs | 31 +++++++++++++++++++ 5 files changed, 66 insertions(+), 13 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 2660dbe4..33b2946f 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -205,6 +205,21 @@ marks everything else UNVERIFIED and states the rule this section now follows everything it reaches is offered as a `NormalizedEqual` candidate for a human to confirm. +**One transformation is not merely unverified — it is measured wrong, and it is +the one that nearly slipped through.** Canonical equivalence looks like decoding +rather than folding: NFC and NFD spell the same characters, and no operator can +type them differently on purpose. But Tally stores a master name as the bytes +that created it and matches on exact codepoints. A voucher naming a UI-created +ledger in its canonically equivalent NFD spelling was **rejected** — +`EXCEPTIONS=1`, `LINEERROR`, ledger does not exist — while the NFC spelling +created it (measured 2026-08-19, TallyPrime 7.1). So they are different masters +to Tally, and folding them here would resolve a source name onto a master Tally +itself keeps apart. NFC stays in the wide fold, where it can only suggest. + +The general lesson is worth more than the case: **a step that reads like +decoding deserves the same evidence as a step that reads like folding.** This +one survived two reviews of the fold by not looking like part of it. + **Trimming a source name is not part of either fold.** `SourceEntity` trims what the document gave it, at the boundary, because leading and trailing space in extracted text is transcription noise; an observed master name is retained @@ -240,9 +255,10 @@ This fold is deliberately **separate from the general comparison key**, which is shared with other contracts for voucher numbers and voucher-type names. §9.4b says nothing about those, and widening the shared fold to serve masters would be the "never to make one caller's case pass" this ADR warns against. One fold per -notion of sameness, each named for the question it answers. Nothing else binds. There is no edit distance, no -phonetic key, no token stemming, and no similarity threshold anywhere in the -implementation. +notion of sameness, each named for the question it answers. + +**Nothing else binds.** There is no edit distance, no phonetic key, no token +stemming, and no similarity threshold anywhere in the implementation. ### 4. Near-misses produce candidates and never resolve diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index a603262d..5e228020 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "edf846830026a31361e7742d655b47914f3361d2e7cbbbb0abafbdab5ff5d0af", + "compatibility_surface_sha256": "3c76b610d00eaeb46cd7ceb6c8a8a890404dac7a60ff64633b709badc73bab2b", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 2e9b1a5b..4c314dc2 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "07105265ea8a0cc424f296b1a1044241ba68d6ce4fe6876a7d788e6deeb7656d" + "sha256": "ff5ea73d4ca63ef3a31b7de5cf1b2332a02a6b95c82f89525174a7015b1917fc" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "edf846830026a31361e7742d655b47914f3361d2e7cbbbb0abafbdab5ff5d0af" + "manifest_sha256": "3c76b610d00eaeb46cd7ceb6c8a8a890404dac7a60ff64633b709badc73bab2b" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index ccfa9763..709e02b8 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1247,18 +1247,24 @@ fn master_identity_key(value: &str) -> String { /// symmetric replacement in a shared key. It lives in `master_binding_keys`, /// on the master side only, which is the side the evidence is about. /// -/// One step here is not on that list: NFC. Canonical equivalence is a property -/// of how the same characters may be encoded, not a claim about which names -/// Tally treats as one, and distinguishing two encodings of an identical name -/// would be distinguishing something no operator can see or type differently. +/// **Canonical equivalence is not folded here, and that one is measured rather +/// than merely unverified.** A voucher naming a UI-created `Cafe\u{301}...` +/// ledger in its canonically equivalent NFD spelling was rejected — +/// `EXCEPTIONS=1`, `LINEERROR`, ledger does not exist — while the NFC spelling +/// created it. Tally stores a master name as the bytes that made it and matches +/// on exact codepoints, so NFC and NFD spellings are *different masters*. +/// Folding them together here would resolve a source name onto a master Tally +/// itself keeps apart. It reads like decoding rather than folding, which is +/// exactly why it nearly stayed. +/// /// Every other unverified step — the reverse hyphen direction, collapsed /// whitespace runs, leading whitespace, Unicode dash variants, non-ASCII case — -/// is deliberately absent. They are not lost: `master_identity_key` still -/// carries them, and everything it reaches is offered as a candidate. +/// is deliberately absent too. None is lost: `master_identity_key` carries them +/// all, and everything it reaches is offered as a candidate. fn verified_fold(value: &str) -> String { // One trailing space, because one is what was sent. let value = value.strip_suffix(' ').unwrap_or(value); - value.nfc().collect::().to_ascii_lowercase() + value.to_ascii_lowercase() } /// The keys a **master** name answers to. diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 93fb424c..77b70273 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -676,6 +676,37 @@ fn a_name_in_another_script_does_not_shed_its_letters_into_a_code() { ); } +#[test] +fn two_encodings_of_one_name_are_two_masters_to_tally_and_so_to_this() { + // Measured 2026-08-19 on TallyPrime 7.1: a voucher naming a UI-created + // ledger in its canonically equivalent NFD spelling was rejected with + // `EXCEPTIONS=1` and a LINEERROR saying the ledger does not exist, while + // the NFC spelling created it. Tally stores the bytes it was given and + // matches on exact codepoints, so these are different masters to Tally and + // must be different masters here. + // + // This is stronger than the UNVERIFIED rows in §9.4b's table: folding it + // is not unproven, it is proven wrong. It reads like decoding rather than + // folding, which is why it nearly stayed in the resolving fold. + let precomposed = "Caf\u{e9} Traders"; + let decomposed = "Cafe\u{301} Traders"; + let catalog = ledgers(&[precomposed, "Beta Supply"]); + let binding = bind_one_name(&catalog, decomposed); + assert_eq!( + binding.bound_name(), + None, + "an NFD source name resolved onto an NFC master Tally keeps apart" + ); + // The wide fold still reaches it, so an operator sees the one master worth + // looking at rather than nothing at all. + assert_eq!(candidate_names(&binding), [precomposed]); + // And the spelling Tally would actually match still binds. + assert_eq!( + bind_one_name(&catalog, precomposed).bound_name(), + Some(precomposed) + ); +} + #[test] fn a_report_bounds_its_own_candidate_allocation() { // A per-entity cap does not bound a report: the clones exist the moment it From 6d953b2a6cea0a9437fd0c4ad86280a5149df3f8 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 11 Sep 2026 14:22:56 +0530 Subject: [PATCH 28/75] fix(master-binding): a delimiter does not unmask, and ASCII is not the alphabet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, both extensions of fixes in this PR, plus a third instance the second one led to. **A separator between a mask and its suffix cleared the mask.** Tokens split on whitespace, so `XXXX - 12345678` processes `-` as a token of its own, and the unconditional `previous_was_mask = masked_here` cleared the state before the digits arrived. The assignment was written as "remember whether this token was a mask" when the question it answers is "is the next token still hidden?" — and those differ for exactly the class of token an operator inserts without thinking. A token with no alphanumeric content is now a delimiter: it neither sets nor clears the mask. An ordinary word still clears it, or nothing downstream of a mask could identify anything again. **The non-ASCII guard was too narrow, in both branches.** It tested `char::is_alphabetic`, which is false for a Devanagari digit, so `AB12345678` still canonicalized to `AB12345678` and reached a sole live `Bank AB12345678`. The numeric branch had the identical hole and no thread named it: `12345678` carries no alphabetic character at all, so it passed the letters guard and the digit-run split emitted `12345678`, reaching an unrelated `Bank 12345678`. So the guard is no longer a list of exclusions. The admitted set is positive — a token yields an identifier only if it is ASCII apart from the dash variants this module already folds as separators — and it guards both branches from one place. The question is not which scripts exist, a list this module has now got wrong twice, but which characters canonicalization is entitled to drop. That is the second time here an ASCII-shaped class silently decided a non-ASCII question; the first was `char::is_alphanumeric` being false for the Devanagari virama, which tore Indic ledger names apart at their joins. ADR 0016 §2 states both rules positively rather than as script exclusions. Three mutation controls: restoring the unconditional mask assignment, narrowing the guard back to `is_alphabetic`, and dropping it from the numeric branch alone each fail a test. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 29 ++++++--- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 40 +++++++++---- .../src/master_binding_tests.rs | 59 +++++++++++++++++++ 5 files changed, 111 insertions(+), 23 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 33b2946f..071e4061 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -130,13 +130,28 @@ identifying a space away than joined: `XXXX 12345678` is the same statement as spellings and for both identifier shapes. Two unrelated ledgers sharing a masked last-eight must reach a near-miss, never a bind. -**A code is a code only in the script it is written in.** Canonical form keeps -ASCII alphanumerics alone, so a name in another script fused to an ASCII suffix -would shed its letters and yield a code the name never contained, binding a -party to an unrelated bank where the ASCII spelling of the same shape did not. -A token holding non-ASCII letters yields no code. This rule has to hold in every -script or the boundary is an ASCII boundary wearing a general name, and the -books this binder reads carry Devanagari, Tamil and Bengali ledger names. +A token carrying no alphanumeric content is a **delimiter**, and a delimiter +does not end a mask: `XXXX - 12345678` says what `XXXX 12345678` says. Reading +the mask state token by token let a single `-` or `/` clear it and walk the +suffix out as a whole account number. An ordinary word does end a mask, or +nothing downstream of one could identify anything again. + +**An identifier may only be built from characters the token actually has.** +Canonical form keeps ASCII alphanumerics, and the digit-run split keeps ASCII +digits, so anything else in a token is discarded in silence — and what survives +is an identifier the name never contained. A name in another script fused to +`AB12345678` yielded that code and reached an unrelated bank; `12345678` +followed by Devanagari numerals yielded that number and did the same. In both +cases the ASCII spelling of the same shape never would. + +So the admitted set is **positive**: a token yields an identifier only if it is +ASCII apart from the dash variants this module already folds as separators. +Guarding "non-ASCII letters" was the first attempt and was too narrow — +`char::is_alphabetic` is false for a Devanagari digit — which is the second time +in this module an ASCII-shaped class silently decided a non-ASCII question. The +question is not which scripts exist; it is which characters canonicalization is +entitled to drop. The books this binder reads carry Devanagari, Tamil and +Bengali ledger names, so the boundary is reached rather than theoretical. **Period labels are recognized by their numbers, not their words.** A token is a period when every number in it reads as a year or a small ordinal — which diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 5e228020..f8daa0fd 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "3c76b610d00eaeb46cd7ceb6c8a8a890404dac7a60ff64633b709badc73bab2b", + "compatibility_surface_sha256": "0e2caaf07b109976183e69924632e5f14d3fa51684397ad0ffb651167fcb39b3", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 4c314dc2..2ed925e1 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "ff5ea73d4ca63ef3a31b7de5cf1b2332a02a6b95c82f89525174a7015b1917fc" + "sha256": "6b3c772768e121e103644ed18976f14a9f96cba7c68dbf9eaae0165bfcfed454" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "3c76b610d00eaeb46cd7ceb6c8a8a890404dac7a60ff64633b709badc73bab2b" + "manifest_sha256": "0e2caaf07b109976183e69924632e5f14d3fa51684397ad0ffb651167fcb39b3" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 709e02b8..202ec776 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1330,7 +1330,16 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro } let masked_here = is_mask_punctuated(token) || is_mask_alphabetic(token); let masked = masked_here || previous_was_mask; - previous_was_mask = masked_here; + // A token carrying no alphanumeric content is a delimiter, not a value, + // and a delimiter between a mask and its suffix does not unmask it: + // `XXXX - 12345678` says exactly what `XXXX 12345678` says. Clearing + // the state here let a `-` or a `/` walk the suffix out as a whole + // account number. + if masked_here { + previous_was_mask = true; + } else if token.chars().any(char::is_alphanumeric) { + previous_was_mask = false; + } let canonical = token .chars() .filter(|character| character.is_ascii_alphanumeric()) @@ -1338,21 +1347,26 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro .collect::(); let digits = canonical.chars().filter(char::is_ascii_digit).count(); let letters = canonical.chars().filter(char::is_ascii_alphabetic).count(); - // Canonicalization keeps only ASCII, so a name written in another - // script and fused to an ASCII suffix yields a code the name never - // contained: a Devanagari party name followed by `AB12345678` - // canonicalized to `AB12345678` and reached an unrelated - // `Bank AB12345678`, while the ASCII-spelled `PartyAB12345678` did not. - // A token identifies by its whole shape or not at all, and that rule - // has to hold in every script or the boundary is an ASCII boundary - // wearing a general name. - let foreign_letters = token + // Canonicalization keeps ASCII, and so does the digit-run split below. + // Everything else in a token is silently discarded, and what survives + // is a code or a number the name never contained: a Devanagari party + // name fused to `AB12345678` yielded `AB12345678`, and `12345678` + // followed by Devanagari digits yielded `12345678` — each reaching an + // unrelated master that the same shape spelled in ASCII never would. + // + // Guarding "non-ASCII letters" was the first attempt and was too + // narrow: `char::is_alphabetic` is false for a Devanagari digit, so the + // numerals walked straight through it. The admitted set is positive + // instead — ASCII, plus the dash variants this module already treats as + // separators — because the question is not which scripts exist but + // which characters canonicalization is entitled to drop. + let foreign_content = token .chars() - .any(|character| !character.is_ascii() && character.is_alphabetic()); + .any(|character| !character.is_ascii() && !DASH_VARIANTS.contains(&character)); if canonical.len() >= MIN_CODE_IDENTIFIER_CHARS && digits >= MIN_CODE_IDENTIFIER_DIGITS && letters >= 2 - && !foreign_letters + && !foreign_content && !masked && !is_period(token) && !is_masked(&canonical) @@ -1372,7 +1386,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro // here carry Devanagari, Tamil and Bengali ledger names, and an // ASCII-only guard read `पार्टी12345678` as digits standing alone, // binding a party to an unrelated `Bank 12345678`. - if token.chars().any(char::is_alphabetic) || masked { + if token.chars().any(char::is_alphabetic) || masked || foreign_content { continue; } for run in token.split(|character: char| { diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 77b70273..415e4e38 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -674,6 +674,39 @@ fn a_name_in_another_script_does_not_shed_its_letters_into_a_code() { entity(&format!("{party} AB12345678")).identifiers().len(), 1 ); + + // Letters were the first guard and were too narrow: `char::is_alphabetic` + // is false for a Devanagari digit, so non-ASCII numerals walked through it + // and canonicalization dropped them just the same. The numeric branch had + // the identical hole, which no thread named — a trailing run of Devanagari + // digits is not alphabetic either, so the ASCII digits before it were + // emitted as a whole account number. + let digits = "\u{967}\u{968}\u{969}"; + for fused in [ + format!("Purchases AB{digits}12345678"), + format!("Purchases 12345678{digits}"), + format!("Purchases {digits}12345678"), + ] { + assert!( + entity(&fused).identifiers().is_empty(), + "{fused} manufactured an identifier out of what canonicalization dropped" + ); + } + let bank = ledgers(&["Bank AB12345678", "Bank 12345678", "Beta Supply"]); + assert_eq!( + bind_one_name(&bank, &format!("Purchases AB{digits}12345678")).bound_name(), + None + ); + assert_eq!( + bind_one_name(&bank, &format!("Purchases 12345678{digits}")).bound_name(), + None + ); + // The dash variants stay admitted: this module already folds them as + // separators, and a punctuated code must still agree with a plain one. + assert_eq!( + entity("Item PH\u{2011}01AB00").identifiers(), + entity("Item PH01AB00").identifiers() + ); } #[test] @@ -897,6 +930,32 @@ fn a_masked_value_identifies_nothing() { ); // A suffix shaped as a code is no less hidden than one shaped as a number. assert!(entity("Purchases XXXX AB12345678").identifiers().is_empty()); + // A delimiter between the mask and its suffix does not unmask it. Reading + // the state token by token, a `-` reset it and the suffix walked out. + for punctuated in [ + "Purchases XXXX - 12345678", + "Purchases **** / 12345678", + "Purchases XXXX . 12345678", + "Purchases XXXX - - 12345678", + ] { + assert!( + entity(punctuated).identifiers().is_empty(), + "{punctuated} exposed its suffix as an identifier" + ); + } + let separated = ledgers(&["Sales XXXX - 12345678", "Beta Supply"]); + assert_eq!( + bind_one_name(&separated, "Purchases XXXX - 12345678").bound_name(), + None + ); + // An ordinary word after a mask does end it, or nothing downstream of one + // could ever identify anything again. + assert_eq!( + entity("Purchases XXXX Invoice 5550001001") + .identifiers() + .len(), + 1 + ); // Ordinary words are not masks, however repetitive: only a run of one // repeated letter is, and one letter alone is an ordinary word. assert_eq!(entity("Purchases Unit 5550001001").identifiers().len(), 1); From 490781336d5b0591dfdee470046dd6dd21e7ffad Mon Sep 17 00:00:00 2001 From: t Date: Fri, 11 Sep 2026 14:46:52 +0530 Subject: [PATCH 29/75] fix(source-draft): read the refusal reason instead of flattening it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all of them the screen claiming more than the DTO says. **`identifier` is not "a number".** The binder extracts two shapes — a numeric run and an alphanumeric code — and the DTO carries only the generic basis, so for any ledger identified by a registration or part code the sentence was false. Carrying the kind through was the other remedy and I did not take it: it would change one word and nothing the operator does, and a field that cannot be acted on differently is a field that later gets read for more than it says. **Every refusal got the same near-miss sentence.** An identifier/name conflict is not a weak match — both sides are strong and they disagree — and flattening it told the operator the opposite of what happened, in the one case where they have something to act on. Identifier/name conflict, identifier conflict and name ambiguity now each get their own lead; the name-ambiguity copy says that nothing measured says which one Tally would pick, which is why nothing was chosen. **An empty candidate list means two opposite things.** A family the name cannot separate is fixed by a fuller source name; a report that ran out of room on earlier rows is not fixed by anything written in this row, so the old copy sent the operator to rewrite a name that was never the problem. `UnboundReason::NoDiscriminatingCandidate` already marks the first case, so the branch keys on the reason rather than on an empty list. `candidates_truncated` cannot carry this: it is `Candidates::is_incomplete()`, true for both Truncated and Withheld. The live-family measurement the third comment rests on is no longer only a comment — `docs/tally/TEST_CORPUS.md` §9.1 now records the counts, the cause and the scope, and the comment cites it. Three mutation controls; each of the three fixes has a test that fails without it, and the two that replace misleading copy also assert the old copy is absent. Co-Authored-By: Claude Opus 5 --- scripts/source-draft-screen.test.tsx | 99 +++++++++++++++++++++++++++- src/SourceDraftScreen.tsx | 47 +++++++++++-- 2 files changed, 138 insertions(+), 8 deletions(-) diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index 4ec2382a..ec96dd90 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -341,11 +341,108 @@ test("lists the bound ledger first without selecting it, and keeps the whole cat // The full catalogue stays reachable; narrowing is a shortcut, not a filter. const all = Array.from(target.querySelectorAll("optgroup")[1].querySelectorAll("option")).map((option) => option.value); expect(all).toEqual(["Alpha placeholder", "Beta placeholder", "Gamma placeholder"]); - expect(host.textContent).toContain("Listed first because a number inside the ledger name matches this source line."); + // `identifier` covers a numeric run and an alphanumeric code alike, and the + // DTO does not say which. Claiming "a number" was wrong for every ledger that + // carries a registration or part code instead. + expect(host.textContent).toContain("Listed first because an identifier inside the ledger name matches this source line."); + expect(host.textContent).not.toContain("a number inside"); expect(host.textContent).not.toContain("recommended"); root.unmount(); }); +test("names the refusal when the name and the identifier point at different ledgers", async () => { + // The one refusal where the operator has something to act on: both sides are + // strong and they disagree. Flattened into the generic near-miss sentence, it + // read as an ordinary weak match and the disagreement never reached anyone. + const conflictCatalog = { + ...catalog, + targets: ["Alpha placeholder", "Beta placeholder", "Gamma placeholder"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: null, + bound_basis: null, + unbound_reason: "master_binding_identifier_name_conflict", + candidates: ["Alpha placeholder", "Gamma placeholder"], + candidate_count: 2, + candidates_truncated: false, + }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(conflictCatalog); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + expect(host.textContent).toContain("matches one existing ledger exactly, while an identifier inside it matches a different one"); + expect(host.textContent).not.toContain("No single ledger matched this source line"); + // Still a refusal: nothing is chosen, and the whole catalogue stays reachable. + expect(host.querySelector("#source-draft-1-entry-0-ledger")?.value).toBe(""); + root.unmount(); +}); + +test("distinguishes the two other refusals that are not weak matches", async () => { + for (const [reason, phrase] of [ + ["master_binding_identifier_conflict", "appears in more than one existing ledger"], + ["master_binding_name_ambiguous", "once case and separators are set aside"], + ] as const) { + mocks.invoke.mockReset(); + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce({ + ...catalog, + targets: ["Alpha placeholder", "Beta placeholder", "Gamma placeholder"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: null, + bound_basis: null, + unbound_reason: reason, + candidates: ["Alpha placeholder", "Gamma placeholder"], + candidate_count: 2, + candidates_truncated: false, + }], + }); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + expect(host.textContent).toContain(phrase); + root.unmount(); + host.remove(); + } +}); + +test("an empty list because the report ran out of room is not a family the name cannot separate", async () => { + // Both arrive with an empty list and a nonzero count, and they call for + // opposite actions. A withheld family is fixed by a fuller source name; + // budget exhaustion on earlier rows is not fixed by anything written here, + // and telling the operator to rewrite the name would be a wild goose chase. + const exhaustedCatalog = { + ...catalog, + targets: ["Alpha placeholder", "Beta placeholder"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: null, + bound_basis: null, + unbound_reason: "master_binding_near_miss", + candidates: [], + candidate_count: 7, + candidates_truncated: true, + }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(exhaustedCatalog); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + expect(host.textContent).toContain("ran out of room to list them"); + expect(host.textContent).not.toContain("tells them apart from none of them"); + expect(host.textContent).not.toContain("Use a fuller source name"); + root.unmount(); +}); + test("lists candidates first for a near miss and states that nothing was chosen", async () => { const nearMissCatalog = { ...catalog, diff --git a/src/SourceDraftScreen.tsx b/src/SourceDraftScreen.tsx index ae552fe4..d4b02d71 100644 --- a/src/SourceDraftScreen.tsx +++ b/src/SourceDraftScreen.tsx @@ -79,11 +79,20 @@ function narrowedTargets(binding: SourceDraftCatalogBinding | null) { /// States what binding did, in the operator's terms. It never says "best", /// "recommended" or "suggested match": nothing here is chosen for anyone, and a /// listed ledger is a shortcut through the list, not an answer. +/// +/// The refusal reason is read, not flattened. Every unbound result used to get +/// the same near-miss sentence, which made an identifier conflict — where the +/// name points at one ledger and the number inside it points at another — look +/// like an ordinary weak match. That is the one case where the operator has +/// real information to act on, and it was the case being hidden. function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: number) { if (!binding) return null; if (binding.bound_target) { + // `identifier` covers both shapes the binder extracts — a numeric run and + // an alphanumeric code such as a registration or part number — and the DTO + // does not say which. So the wording does not claim a number. const how = binding.bound_basis === "identifier" - ? "a number inside the ledger name" + ? "an identifier inside the ledger name" : binding.bound_basis === "exact_name" ? "the exact ledger name" : "the same ledger name, differently written"; @@ -94,14 +103,38 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: } const shown = binding.candidates.length; if (shown === 0) { - // Measured against live books: this source name reaches a whole family of - // ledgers and separates none of them, so listing an arbitrary slice put the - // right one out of view about a third of the time. Say that, rather than - // print a count of nothing. - return `This source line matches ${binding.candidate_count} existing ledgers and tells them apart from none of them, so none is listed. Use a fuller source name, or choose from the full list of ${total}.`; + // Two different facts arrive here with an empty list and a nonzero count, + // and they call for opposite actions. A withheld family is the binder + // refusing to print an arbitrary slice of ledgers this name cannot separate + // — slicing put the right one out of view about a third of the time across + // sixteen live catalogues, recorded with its counts and scope in + // `docs/tally/TEST_CORPUS.md` §9.1 — and a fuller source name fixes it. Budget exhaustion is + // this report running out of room on earlier rows; the source name is fine + // and nothing the operator writes here would change it. + if (binding.unbound_reason === "master_binding_no_discriminating_candidate") { + return `This source line matches ${binding.candidate_count} existing ledgers and tells them apart from none of them, so none is listed. Use a fuller source name, or choose from the full list of ${total}.`; + } + return `This source line matches ${binding.candidate_count} existing ledgers, but this report ran out of room to list them. Choose from the full list of ${total}.`; } const listed = binding.candidates_truncated ? `${shown} of ${binding.candidate_count}` : `${shown}`; - return `No single ledger matched this source line, so nothing is chosen. ${listed} possible ${shown === 1 ? "ledger is" : "ledgers are"} listed first; the full list of ${total} follows.`; + const lead = catalogRefusalLead(binding.unbound_reason); + return `${lead} ${listed} possible ${shown === 1 ? "ledger is" : "ledgers are"} listed first; the full list of ${total} follows.`; +} + +/// Why binding refused, where the reason changes what the operator should look +/// at. A conflict is not a weak match: both sides of it are strong, and they +/// disagree. +function catalogRefusalLead(reason: string | null) { + switch (reason) { + case "master_binding_identifier_name_conflict": + return "This source name matches one existing ledger exactly, while an identifier inside it matches a different one. They disagree, so nothing is chosen."; + case "master_binding_identifier_conflict": + return "An identifier in this source line appears in more than one existing ledger, so it cannot say which."; + case "master_binding_name_ambiguous": + return "More than one existing ledger carries this name once case and separators are set aside, and nothing measured says which one Tally would pick."; + default: + return "No single ledger matched this source line, so nothing is chosen."; + } } function hasStartedProposal(row: SourceDraftRow) { From dc42d9067807491ac94572add996bd608a5e0443 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 11 Sep 2026 15:16:30 +0530 Subject: [PATCH 30/75] fix(master-binding): the trailing space was directional too, and three more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight review findings. Three are correctness, and one of them is the same mistake this PR had already made once. **The trailing space was stripped on both sides.** The hyphen was moved onto the master side of the index precisely because §9.4b's separator result is directional; the trailing space is the row directly below it, directional for the same reason, and it stayed in the shared fold. A master spelled `Sales ` therefore resolved from a clean `Sales`, which was never sent. `verified_fold` no longer strips anything; `source_binding_key` drops one trailing space, because the source is the side that was measured. A test I wrote asserted the wrong direction, and one test's premise disappeared entirely — `Alpha Traders` and `Alpha Traders ` no longer collide, so the ambiguity case is rewritten around case-only siblings, which do. **A mask was a list of glyphs.** `........12345678` and `____ 12345678` were not masks, so the suffix left as an account number. Enumerating glyphs is the losing move this module has now made three times — period spellings, then scripts, now mask characters. A run of three or more of one repeated non-alphanumeric is a mask by shape; the four glyphs stay as an additional test, since a lone `*` is a mask and shape alone would stop seeing it. **A date fused into a code-shaped token was still a code.** `is_plausible_date` guarded the numeric branch only, and `is_period`'s eight-digit case admits a year followed by a year, which `20250911` is not. `DATED20250911` bound two unrelated ledgers to each other. The rest bound resources and correct documents. - The catalog had no aggregate byte bound: 20,000 names of 16,384 characters satisfies both documented limits and is 327 MB before the indexes are built. Accumulated as the iterator is consumed, so it refuses before retaining the next name. - `collect_candidates` is memoized per distinct source key and identifier reach, capped at 1,024 entries so a draft of distinct names cannot trade the stall for the memory the bound above exists to prevent. The mutation control caught my first memo key omitting the identifier reach, which would have handed one entity another's candidates. - ADR 0016 §3 claimed `SourceEntity` trims source names. It does not, and never did; the section now describes what the constructor actually does. - `bindings_state: "complete"` means the pass ran, not that everything bound. The TypeScript comment said the opposite, which is the failure this module exists to prevent, one layer up. - TEST_CORPUS §9's identifier-coverage row contradicted its own evidence in the direction that made the rule look better supported. The absence is of the numeric shape only; one code identifier was observed. `TALLY_PROTOCOL_REFERENCE.md` §9.4c records the live-catalogue family behaviour and the rule it implies, with §9.1 of the corpus keeping the counts. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 31 ++- docs/tally/TALLY_PROTOCOL_REFERENCE.md | 25 ++ docs/tally/TEST_CORPUS.md | 12 +- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 8 +- .../bridge-tally-core/src/master_binding.rs | 127 ++++++++++- .../src/master_binding_tests.rs | 215 ++++++++++++++++-- src/source-draft-types.ts | 11 +- 8 files changed, 377 insertions(+), 54 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 071e4061..ec2bec34 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -65,8 +65,12 @@ The constructor refuses, rather than degrades, on: does not identify one master, nothing downstream is meaningful; - **an identifier hint that yields no identifier** — `IdentifierHintUnusable`. A hint that silently does nothing is a trap (P7); -- bounds violations on entry count, entity count, name length, and **hint - count**. The last is checked as the hints arrive rather than on the finished +- bounds violations on entry count, entity count, name length, **total catalog + name bytes**, and **hint count**. The byte bound is not redundant with the + other two: 20,000 names of 16,384 characters satisfies both and is 327 MB + before the constructor builds its keys, tokens and four indexes over them. It + is accumulated as the iterator is consumed, so a lazy catalog fails before the + next name is retained rather than after all of them are. The last is checked as the hints arrive rather than on the finished set: hints deduplicate, so a million repeated ones fold to a single identifier and the finished set never exceeds its bound, while every one of them has already been scanned and copied. Each hint yields at least one identifier or @@ -235,14 +239,21 @@ The general lesson is worth more than the case: **a step that reads like decoding deserves the same evidence as a step that reads like folding.** This one survived two reviews of the fold by not looking like part of it. -**Trimming a source name is not part of either fold.** `SourceEntity` trims -what the document gave it, at the boundary, because leading and trailing space -in extracted text is transcription noise; an observed master name is retained -byte for byte, because a caller writes it back. So a source reading -`" Alpha Traders"` reaches `Alpha Traders`, while a *master* spelled -`" Alpha Traders"` does not resolve from a clean source name — it is offered. -The asymmetry is deliberate and is the P3 rule, not a claim about what Tally -folds. +**Neither side is trimmed.** An earlier draft of this section said +`SourceEntity` trims what the document gave it. It does not — `validated_name` +bounds a name and returns it unchanged, and a master is retained byte for byte +because a caller writes it back. The claim was written from what seemed +reasonable rather than from the constructor, and a contract that describes +behaviour the implementation does not have is worse than no contract: a consumer +following it expects a bind and gets a candidate. + +What actually happens is narrower, and directional. **One trailing space is +dropped from the source key, and from nothing else** — because that is the +shape §9.4b measured: a name carrying a trailing space was *supplied* against a +clean live master and Tally matched it. So `"Alpha Traders "` resolves to a +live `Alpha Traders`, while a master spelled `"Alpha Traders "` does not resolve +from a clean source name; it is offered as a candidate. Leading whitespace is +unverified in both directions and is dropped from neither. **This was got wrong first, and the correction is the useful record.** An earlier version of this ADR claimed the fold "stops exactly where Tally stops" diff --git a/docs/tally/TALLY_PROTOCOL_REFERENCE.md b/docs/tally/TALLY_PROTOCOL_REFERENCE.md index 3326b271..c543fa29 100644 --- a/docs/tally/TALLY_PROTOCOL_REFERENCE.md +++ b/docs/tally/TALLY_PROTOCOL_REFERENCE.md @@ -1013,6 +1013,31 @@ Whether stock items, groups and voucher types match by the same rule is UNVERIFI says nothing about voucher numbers — a fold shared between master names and voucher numbers is assuming something nobody has measured. +### 9.4c Real catalogues carry families a partial name cannot separate + +**VERIFIED 2026-09-10** for the counts, across 16 loaded companies on both lab instances; the rule +built on them is PARTIAL. `TEST_CORPUS.md` §9.1 carries the procedure, the per-company figures and +what they do not cover. + +Live books name parties in **sequentially-numbered families** — one observed catalogue runs a single +prefix across more than a hundred ledgers that differ only in a trailing number. A source name that +is a truncation of one of them reaches the whole family and distinguishes no member of it. + +**Why that is a protocol-level fact and not an implementation detail:** any client matching a +supplied name against a read catalogue meets it, and the tempting response — offer the first N and +let a human pick — is measured wrong. Listing an arbitrary capped slice of such a family **put the +intended master outside the offered list about a third of the time** (present in 65.6% of lists, +against 100% once families beyond the cap were withheld and counted instead). + +> **RULE: where a supplied name reaches a family it does not separate, report the count and withhold +> the list. An arbitrary slice of a family is not a shortlist — it is a wrong answer that looks like +> a shortlist.** + +The scope is narrow and matters: the catalogue side is live, and every *source* name in the +measurement is a fabricated mutation of a live name. It measures the rule against real naming +habits, not against real operator input. + + ### 9.5 Identity after write **VERIFIED.** `LASTMID` is **0** on successful master creates — unusable for master identity; diff --git a/docs/tally/TEST_CORPUS.md b/docs/tally/TEST_CORPUS.md index 4e0dc183..d6adf645 100644 --- a/docs/tally/TEST_CORPUS.md +++ b/docs/tally/TEST_CORPUS.md @@ -357,17 +357,17 @@ them is **PARTIAL**. Scope of each, so neither is read for more than it covers: | claim | confidence | what establishes it | | --- | --- | --- | | The ten ledgers exist in that company and nowhere else | **VERIFIED** | `CREATED=10, ALTERED=0, ERRORS=0`, then a readback of the ledger list naming all ten, plus a readback of a guard company showing none | -| No book carried an embedded identifier before this | **VERIFIED** | all 16 loaded companies read through the `StandardLedgerCatalogV1` request, responses written to files and parsed from the files; 470 names, 0 numeric and 1 code identifier | +| No book carried an embedded **numeric** identifier before this | **VERIFIED** | all 16 loaded companies read through the `StandardLedgerCatalogV1` request, responses written to files and parsed from the files; 470 names, **0 numeric**. Exactly **one** name yielded a *code* identifier, so the absence is of the numeric shape only — stated narrowly because these counts are what justify the rule's strictness | | The identifier rule behaves correctly against live-read names | **PARTIAL** | exercised against these ten seeded names only, on one instance, one licence tier, one Tally build. Fabricated *source* names against live *catalogue* names — no real source document has been bound end to end | | The shipped consumer path runs end to end against a real instance | **VERIFIED 2026-09-11**, nine rows of ten | the branch's own `bridge_mcp` binary driven over stdio against licensed TallyPrime 7.1 Silver, reading this company's catalogue over the wire and returning the binder's report. One row is **PENDING** a re-run after the fold was narrowed — see "The end-to-end slice" below | | Binding is safe on catalogues generally | **UNVERIFIED** | one company, one instance, one Tally build, and every *source* name fabricated. No engagement has run a real document through this path; the mutation sweep is fabricated mutations of live names, not observed operator input | **Added 2026-09-10.** Ten ledgers prefixed `MB `, seeded so the master-binding -identifier rule has live coverage. Before this, **no book on either instance carried an -embedded identifier**: across 470 live ledger names read from all 16 loaded companies, -zero yielded a numeric identifier and exactly one yielded a code identifier. The rule that -distinguishes `bridge_tally_core::master_binding` from fuzzy matching was qualified by -fabricated data alone. +identifier rule has live coverage. Before this, across 470 live ledger names read from all +16 loaded companies, **not one yielded a numeric identifier and exactly one yielded a code +identifier** — so the numeric rule had no live coverage at all and the code rule had a +single instance. The rule that distinguishes `bridge_tally_core::master_binding` from fuzzy +matching was otherwise qualified by fabricated data alone. | ledger | what it exercises | | --- | --- | diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 32d07c3d..025eb899 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "a275ad3b7c56e5cf2d5ce4e034ee739ada42326794f44d14d83cb2f9e793669c", + "compatibility_surface_sha256": "b11e2696a09d535b9bdc98bc6be30e728dd8761d1e6019dbb12a7c377c90a46b", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 52211301..ec934f8a 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -35,7 +35,7 @@ }, { "path": "docs/tally/TALLY_PROTOCOL_REFERENCE.md", - "sha256": "9fcd17b03023839f3d85ed0054918b2e4efc58162cd075b3449c133a5a9035ff" + "sha256": "94d94259b2a6c7ae04ad087678a1058cf75eca4def1e2c6cf33405f6a7f16980" }, { "path": "docs/tally/compatibility/README.md", @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "9aa519cc57a9e55044e51e3b971374b5320060c3173101e72fe71a671adb81de" + "sha256": "a3a13f59b54853dd6a4d32be9d2fb427f2c02c491eafc5857b3152cbaff6482d" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -775,7 +775,7 @@ }, { "path": "src/source-draft-types.ts", - "sha256": "85c2252e417dce193650715a0913d12f77ea4b69586adeb8aa9f643df60f9cd4" + "sha256": "cc7885a1f6942a63a7768f207a21198b94e601c3413fa3600f22ac05b5664939" }, { "path": "src/source-draft.css", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "a275ad3b7c56e5cf2d5ce4e034ee739ada42326794f44d14d83cb2f9e793669c" + "manifest_sha256": "b11e2696a09d535b9bdc98bc6be30e728dd8761d1e6019dbb12a7c377c90a46b" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index d5dfa4da..cdda96b8 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -18,6 +18,11 @@ use unicode_normalization::UnicodeNormalization; /// Most masters one catalog may carry. pub const MAX_CATALOG_ENTRIES: usize = 20_000; +/// The aggregate a catalog's names may occupy, independent of how they divide +/// into entries. Eight mebibytes is far above any observed book — the largest +/// catalogue read here is 470 names — and far below the 327 MB the per-name and +/// per-entry bounds admit between them. +const MAX_CATALOG_NAME_BYTES: usize = 8 * 1024 * 1024; /// Most entities one binding request may name. This must stay at or above what /// a consumer's own parser admits: Bridge's source-draft parser accepts 2,000 /// vouchers of 20 entries, and a bound below that turned a valid draft into a @@ -215,8 +220,9 @@ pub enum UnboundReason { /// none of them — a truncated `DN Party 0` against `DN Party 001`…`120`. /// Listing an arbitrary capped slice of such a family put the right master /// out of view about a third of the time, so the family is counted and - /// deliberately not listed. Measured across sixteen live catalogues; - /// `docs/tally/TEST_CORPUS.md` §9.1 carries the counts and their scope. + /// deliberately not listed. The behaviour and its rule are + /// `TALLY_PROTOCOL_REFERENCE.md` §9.4c; `TEST_CORPUS.md` §9.1 carries the + /// counts and their scope. NoDiscriminatingCandidate, /// No rule produced a candidate. The master is probably missing. NoCandidate, @@ -637,7 +643,7 @@ impl SourceEntity { Ok(Self { position, key: master_identity_key(&name), - binding_key: verified_fold(&name), + binding_key: source_binding_key(&name), name, identifiers, }) @@ -700,12 +706,23 @@ impl MasterCatalog { S: AsRef, { let mut entries = Vec::new(); + let mut accepted_bytes = 0_usize; let mut by_name = BTreeMap::new(); for name in names { if entries.len() >= MAX_CATALOG_ENTRIES { return Err(MasterBindingError::CatalogTooLarge); } let name = validated_name(name.as_ref())?; + // Per-name and per-count bounds do not bound the product of the + // two: 20,000 names of 16,384 characters satisfies both and is + // 327 MB of ASCII before this constructor builds its keys, tokens + // and four indexes over them. Accumulated as the iterator is + // consumed, so a lazy catalog fails before the next name is + // retained rather than after all of them are. + accepted_bytes = accepted_bytes.saturating_add(name.len()); + if accepted_bytes > MAX_CATALOG_NAME_BYTES { + return Err(MasterBindingError::CatalogTooLarge); + } if by_name.contains_key(&name) { return Err(MasterBindingError::CatalogDuplicateName); } @@ -824,17 +841,40 @@ pub fn bind( return Err(MasterBindingError::TooManySourceEntities); } let mut budget = MAX_REPORT_CANDIDATE_BYTES; + let mut memo = CandidateMemo::new(); Ok(BindingReport { class: catalog.class, catalog: catalog.fingerprint, entities: entities .iter() - .map(|entity| bind_one(catalog, entity, &mut budget)) + .map(|entity| bind_one(catalog, entity, &mut budget, &mut memo)) .collect(), }) } -fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) -> EntityBinding { +/// What `collect_candidates` produced for one distinct source name, keyed by +/// the only two things it reads: the source key, and the masters the entity's +/// identifiers reached. +/// +/// A draft may repeat one ledger name across its rows, and the search is not +/// cheap when it does: a truncated name against a large prefix family +/// materializes and clones that whole family, once per row. The candidate byte +/// budget bounds only what is serialized afterwards. +/// +/// Capped rather than unbounded, because the memo is itself a copy of every +/// candidate list it holds — a draft of 40,000 *distinct* names would trade the +/// stall for the memory the aggregate bounds elsewhere exist to prevent. The +/// repeated-name case, which is the one that stalls, needs very few entries. +type CandidateMemo = BTreeMap<(String, BTreeSet), (Vec<(usize, CandidateRule)>, usize)>; + +const MAX_CANDIDATE_MEMO_ENTRIES: usize = 1_024; + +fn bind_one( + catalog: &MasterCatalog, + entity: &SourceEntity, + budget: &mut usize, + memo: &mut CandidateMemo, +) -> EntityBinding { let exact = catalog.by_name.get(&entity.name).copied(); // Rule one: the identifier is the key, the name is a hint. A name @@ -894,6 +934,7 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) exact, &identifier_matches, budget, + memo, ) } else if let Some(index) = exact { BindingStatus::Bound { @@ -911,6 +952,7 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) exact, &identifier_matches, budget, + memo, ) } else if let Some(matched) = identifier_matches.iter().copied().next() { // A decisive identifier, with no byte-exact name to outrank it. This is @@ -940,6 +982,7 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) exact, &identifier_matches, budget, + memo, ), None => { let (candidates, masters_found) = @@ -970,8 +1013,19 @@ fn unresolved_status( exact: Option, identifier_matches: &BTreeSet, budget: &mut usize, + memo: &mut CandidateMemo, ) -> BindingStatus { - let (mut candidates, masters_found) = collect_candidates(catalog, entity, identifier_matches); + let memo_key = (entity.key.clone(), identifier_matches.clone()); + let (mut candidates, masters_found) = match memo.get(&memo_key) { + Some(remembered) => remembered.clone(), + None => { + let computed = collect_candidates(catalog, entity, identifier_matches); + if memo.len() < MAX_CANDIDATE_MEMO_ENTRIES { + memo.insert(memo_key, computed.clone()); + } + computed + } + }; if let Some(index) = exact { if !candidates.iter().any(|(candidate, _)| *candidate == index) { candidates.push((index, CandidateRule::NormalizedEqual)); @@ -1064,8 +1118,8 @@ fn collect_candidates( // arbitrary capped slice of one omitted the right master about a third of // the time against live books — 65.6% present, against 100% once families // over the bound were withheld. Counted, and withheld rather than listed. - // See `docs/tally/TEST_CORPUS.md` §9.1 for the counts, the cause, and what - // they do not cover. + // `TALLY_PROTOCOL_REFERENCE.md` §9.4c states the rule; `TEST_CORPUS.md` + // §9.1 carries the counts, the cause and what they do not cover. let withheld = if extending.len() > MAX_PREFIX_FAMILY { extending.clone() } else { @@ -1266,11 +1320,21 @@ fn master_identity_key(value: &str) -> String { /// is deliberately absent too. None is lost: `master_identity_key` carries them /// all, and everything it reaches is offered as a candidate. fn verified_fold(value: &str) -> String { - // One trailing space, because one is what was sent. - let value = value.strip_suffix(' ').unwrap_or(value); value.to_ascii_lowercase() } +/// The key a **source** name is looked up by. +/// +/// One trailing space is dropped here and nowhere else, because that is how it +/// was measured: §9.4b *supplied* a name carrying a trailing space against a +/// clean live master and Tally matched it. The reverse — a master carrying a +/// trailing space, reached from a clean source name — was never sent, and +/// stripping on the master side quietly asserted it. Same directional trap as +/// the hyphen, one row further down the same table. +fn source_binding_key(value: &str) -> String { + verified_fold(value.strip_suffix(' ').unwrap_or(value)) +} + /// The keys a **master** name answers to. /// /// Its own, and — because a source space was measured matching a master hyphen @@ -1371,6 +1435,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro && digits >= MIN_CODE_IDENTIFIER_DIGITS && letters >= 2 && !foreign_content + && !carries_plausible_date(&canonical) && !masked && !is_period(token) && !is_masked(&canonical) @@ -1445,11 +1510,38 @@ fn is_mask_alphabetic(token: &str) -> bool { } fn is_mask_punctuated(token: &str) -> bool { - token + if token .chars() .any(|character| matches!(character, '*' | '#' | '\u{2022}' | '\u{00d7}')) + { + return true; + } + // Those four glyphs say "masked" on their own. Everything else has to be + // recognized by **shape**, because enumerating them lost to `........1234` + // and `____ 1234` and would have lost to the next spelling too. A run of + // one repeated non-alphanumeric is a mask; ordinary punctuation does not + // repeat that way, so `S.K. Traders` and `(5550001001)` are untouched. + let mut run: Option<(char, usize)> = None; + for character in token.chars() { + if character.is_alphanumeric() { + run = None; + continue; + } + run = match run { + Some((previous, count)) if previous == character => Some((character, count + 1)), + _ => Some((character, 1)), + }; + if run.is_some_and(|(_, count)| count >= MIN_MASK_RUN) { + return true; + } + } + false } +/// Three, because two is a legitimate ellipsis of a kind operators do write and +/// a doubled separator is ordinary noise. Erring long here only costs a bind. +const MIN_MASK_RUN: usize = 3; + /// The separators an operator writes a range with. The comparison key already /// folds these dash variants to ASCII; the period boundary has to admit the /// same set, or `FY2025\u{2013}26` fuses where `FY2025-26` splits. @@ -1551,6 +1643,19 @@ fn part_reads_as_period(canonical: &str, any_number: &mut bool) -> bool { true } +/// Whether any maximal digit run inside a token reads as a date. +/// +/// `is_plausible_date` guarded the numeric branch only, so `DATED20250911` +/// reached the code branch untouched: `is_period` does not see it either, +/// because its eight-digit case admits a year followed by a year and `0911` is +/// neither. Two unrelated ledgers sharing a fused date label then bound to each +/// other on it. +fn carries_plausible_date(canonical: &str) -> bool { + canonical + .split(|character: char| !character.is_ascii_digit()) + .any(is_plausible_date) +} + /// An eight-digit run that reads as a calendar date in any order this project /// admits is a date, not an identifier. Recognizing only `YYYYMMDD` left /// `01012026` binding a source to an unrelated master that shares its period diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 415e4e38..537ebbd7 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -84,34 +84,69 @@ fn unusable_names_are_refused_at_the_boundary() { } #[test] -fn an_observed_master_name_is_retained_verbatim_while_a_source_name_is_trimmed() { - // A caller writes the bound name back to Tally byte for byte. Trimming an - // observed name here would report a spelling that does not exist and - // refuse at the write gate with no explanation. - let catalog = ledgers(&["Alpha Traders ", "Beta Supply"]); - assert_eq!(catalog.names().next(), Some("Alpha Traders ")); - let binding = bind_one_name(&catalog, "Alpha Traders"); - assert_eq!(binding.bound_name(), Some("Alpha Traders ")); - assert_eq!(binding.source_name, "Alpha Traders"); - // One trailing space is what §9.4b sent. *Leading* whitespace is on its - // unverified list, so a master carrying it surfaces as a candidate instead - // of resolving — and is retained verbatim either way. +fn a_trailing_space_is_dropped_on_the_side_that_was_measured() { + // §9.4b *supplied* a name carrying a trailing space against a clean live + // master, and Tally matched it. That direction resolves. + let clean = ledgers(&["Alpha Traders", "Beta Supply"]); + let binding = bind_one_name(&clean, "Alpha Traders "); + assert_eq!(binding.bound_name(), Some("Alpha Traders")); + assert_eq!( + binding.source_name, "Alpha Traders ", + "a source name is recorded as the document wrote it, not as the fold read it" + ); + + // The reverse was never sent. Stripping on the master side asserted it + // silently — the same directional trap as the hyphen, one row down the same + // table — so a master carrying a trailing space is now a candidate. + let trailing = ledgers(&["Alpha Traders ", "Beta Supply"]); + assert_eq!(trailing.names().next(), Some("Alpha Traders ")); + let binding = bind_one_name(&trailing, "Alpha Traders"); + assert_eq!(binding.bound_name(), None); + assert_eq!(candidate_names(&binding), ["Alpha Traders "]); + + // Leading whitespace is unverified in both directions, and is not trimmed + // from either side. A caller writes the bound name back byte for byte, so + // an observed name is never tidied. let leading = ledgers(&[" Alpha Traders", "Beta Supply"]); let binding = bind_one_name(&leading, "Alpha Traders"); assert_eq!(binding.bound_name(), None); assert_eq!(candidate_names(&binding), [" Alpha Traders"]); + let binding = bind_one_name(&clean, " Alpha Traders"); + assert_eq!(binding.bound_name(), None); } #[test] -fn names_differing_only_in_surrounding_whitespace_are_an_ambiguity_not_a_refused_catalog() { - let catalog = ledgers(&["Alpha Traders", "Alpha Traders "]); - let binding = bind_one_name(&catalog, "Alpha Traders"); - // Byte equality still picks the exact one; the near-identical sibling is - // not a reason to fail the whole read. - assert_eq!(binding.bound_name(), Some("Alpha Traders")); - let other = bind_one_name(&catalog, "alpha traders"); +fn near_identical_masters_are_an_ambiguity_where_they_collide_and_never_a_refused_catalog() { + // A catalog holding two names one fold or another could merge must not fail + // the whole read. Whether they are *ambiguous* is a separate question, and + // the answer changed when the fold was held to what §9.4b measured. + + // Case-only siblings do collide: ASCII case folding is verified, so both + // answer to one key and a third spelling resolves to neither. + let cased = ledgers(&["Alpha Traders", "alpha traders"]); + assert_eq!( + bind_one_name(&cased, "Alpha Traders").bound_name(), + Some("Alpha Traders"), + "byte equality still picks the exact one" + ); + let other = bind_one_name(&cased, "ALPHA TRADERS"); assert_eq!(reason(&other), UnboundReason::NameAmbiguous); - assert_eq!(candidate_names(&other), ["Alpha Traders", "Alpha Traders "]); + assert_eq!(candidate_names(&other), ["Alpha Traders", "alpha traders"]); + + // A trailing space no longer collides, because it is dropped only on the + // side that was measured. The catalog is still accepted, and each master is + // reachable — the clean one from a source carrying the space, the other + // only byte-exactly. + let spaced = ledgers(&["Alpha Traders", "Alpha Traders "]); + assert_eq!( + bind_one_name(&spaced, "alpha traders ").bound_name(), + Some("Alpha Traders") + ); + assert_eq!( + bind_one_name(&spaced, "Alpha Traders ").bound_name(), + Some("Alpha Traders "), + "byte equality outranks the fold" + ); } #[test] @@ -740,6 +775,124 @@ fn two_encodings_of_one_name_are_two_masters_to_tally_and_so_to_this() { ); } +#[test] +fn a_date_fused_into_a_code_shaped_token_is_still_a_date() { + // `is_plausible_date` guarded the numeric branch only, and `is_period` + // never sees this one: its eight-digit case admits a year followed by a + // year, and `0911` is neither. So `DATED20250911` cleared every code test + // and two unrelated ledgers bound to each other on a shared date label. + for label in [ + "DATED20250911", + "DT20250911", + "INV20250911", + "DATED11092025", + ] { + assert!( + entity(&format!("Purchases {label}")) + .identifiers() + .is_empty(), + "{label} was treated as a code identifier" + ); + } + let catalog = ledgers(&["Sales DATED20250911", "Beta Supply"]); + assert_eq!( + bind_one_name(&catalog, "Purchases DATED20250911").bound_name(), + None, + "a shared date label must not bind two unrelated ledgers" + ); + // A run no calendar would produce is still a code, and a longer run is not + // a date at all — the test is on eight digits exactly. + assert_eq!(entity("Item PH01AB00").identifiers().len(), 1); + assert_eq!(entity("Party AB5550001001").identifiers().len(), 1); +} + +#[test] +fn a_catalog_is_bounded_by_total_bytes_and_not_only_by_count() { + // Both documented bounds can hold while their product does not: 20,000 + // names of 16,384 characters satisfies each and is 327 MB before the + // constructor builds keys, tokens and four indexes over them. The bound has + // to be on the aggregate, and has to fire while the iterator is consumed. + let long = "N".repeat(MAX_NAME_CHARS); + let many = (0..600) + .map(|index| format!("{index:04}{}", &long[..MAX_NAME_CHARS - 4])) + .collect::>(); + assert_eq!( + MasterCatalog::new(MasterClass::Ledger, &many), + Err(MasterBindingError::CatalogTooLarge) + ); + // A catalog far larger than any observed book still loads: the largest read + // here is 470 names, so the bound must not be reachable in practice. + let ordinary = (0..5_000) + .map(|index| format!("Placeholder Party {index:05}")) + .collect::>(); + assert!(MasterCatalog::new(MasterClass::Ledger, &ordinary).is_ok()); +} + +#[test] +fn repeating_one_source_name_does_not_repeat_the_search_or_change_the_answer() { + // A draft may name one ledger on every row, and the candidate search is not + // cheap when the name reaches a family. Remembering it must not change what + // the report says — the memo is keyed on the source key and the masters its + // identifiers reached, which is all `collect_candidates` reads. + let names = (0..60) + .map(|index| format!("Acme Branch {index:05}")) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + + let alone = bind_one_name(&catalog, "Acme Branch"); + let repeated = (0..40) + .map(|position| SourceEntity::new(position, "Acme Branch").expect("valid")) + .collect::>(); + let report = bound(&catalog, &repeated); + assert_eq!(report.totals().requested, 40); + assert_eq!(report.totals().bound, 0); + for entity in report.entities() { + assert_eq!( + entity.status, alone.status, + "remembering the search changed the answer" + ); + } + + // Same key, different spelling: one is byte-exact and one is not, and the + // shared memo must not leak the exact hit into the other's answer. + let mixed = vec![ + SourceEntity::new(0, "Acme Branch 00007").expect("valid"), + SourceEntity::new(1, "acme branch 00007").expect("valid"), + ]; + let report = bound(&catalog, &mixed); + assert_eq!(report.entities()[0].bound_name(), Some("Acme Branch 00007")); + assert_eq!( + report.entities()[1].bound_name(), + Some("Acme Branch 00007"), + "a normalized hit is still a hit" + ); + + // Same source *name*, different identifier hints. The key is identical, so + // a memo keyed on the key alone would hand the second entity the first + // one's candidates — a different pair of ledgers entirely. This is the case + // that proves the second half of the memo key, and nothing else reaches it. + let shared = ledgers(&[ + "Party Alpha (5550001009)", + "Party Beta (5550001009)", + "Party Gamma (5550001007)", + "Party Delta (5550001007)", + ]); + let hinted = vec![ + SourceEntity::with_identifier_hints(0, "Zeta Holdings", ["5550001009"]).expect("valid"), + SourceEntity::with_identifier_hints(1, "Zeta Holdings", ["5550001007"]).expect("valid"), + ]; + let report = bound(&shared, &hinted); + assert_eq!( + candidate_names(&report.entities()[0]), + ["Party Alpha (5550001009)", "Party Beta (5550001009)"] + ); + assert_eq!( + candidate_names(&report.entities()[1]), + ["Party Delta (5550001007)", "Party Gamma (5550001007)"], + "the memo handed one entity another's candidates" + ); +} + #[test] fn a_report_bounds_its_own_candidate_allocation() { // A per-entity cap does not bound a report: the clones exist the moment it @@ -960,9 +1113,31 @@ fn a_masked_value_identifies_nothing() { // repeated letter is, and one letter alone is an ordinary word. assert_eq!(entity("Purchases Unit 5550001001").identifiers().len(), 1); assert_eq!(entity("Purchases A 5550001001").identifiers().len(), 1); + // A mask is a shape, not a list of glyphs. Enumerating four of them lost + // to a dotted and an underscored mask, and would have lost to the next. + for shaped in [ + "Purchases ........12345678", + "Purchases ____ 12345678", + "Purchases ~~~ 12345678", + "Purchases --- 12345678", + ] { + assert!( + entity(shaped).identifiers().is_empty(), + "{shaped} exposed its suffix as an identifier" + ); + } + let dotted = ledgers(&["Sales ........12345678", "Beta Supply"]); + assert_eq!( + bind_one_name(&dotted, "Purchases ........12345678").bound_name(), + None + ); // Ordinary punctuation around a whole number is not a mask. assert_eq!(entity("Party (5550001001)").identifiers().len(), 1); assert_eq!(entity("Party 5550001-002").identifiers().len(), 1); + // Names punctuate; they do not repeat punctuation. Two of a character is + // ordinary noise, so the run has to be longer than an operator's slip. + assert_eq!(entity("S.K. Traders 5550001001").identifiers().len(), 1); + assert_eq!(entity("Party -- 5550001001").identifiers().len(), 1); // And a number following an ordinary word is untouched. assert_eq!(entity("Invoice 5550001001").identifiers().len(), 1); } diff --git a/src/source-draft-types.ts b/src/source-draft-types.ts index ebe32e55..73e458c8 100644 --- a/src/source-draft-types.ts +++ b/src/source-draft-types.ts @@ -85,8 +85,15 @@ export type SourceDraftCatalogTargets = { source_sha256: string; targets: string[]; bindings: SourceDraftCatalogBinding[]; - /// "complete" when every source entry was bound; "unavailable" when the - /// narrowing pass could not run. An empty list alone cannot say which. + /// Whether the narrowing pass **ran**, not whether it resolved anything. + /// "complete" means every source entry was put through binding and carries a + /// result — which for many of them will be a near miss or nothing at all; + /// "unavailable" means the pass could not run, so an empty list says nothing. + /// An empty list alone cannot distinguish the two, which is why this exists. + /// + /// It is **not** an all-bound signal and must not gate resolution: a draft + /// whose every entry is unmatched still reports "complete". Read the bindings + /// for that. bindings_state: "complete" | "unavailable"; evidence: { request_sha256: string; response_sha256: string; bytes: number; state: "complete" }; }; From 12f31dce0778b9dba4b8f139a699f00d7aaad69e Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 03:32:36 +0530 Subject: [PATCH 31/75] fix(source-draft): name both shapes of identifier conflict, and cite 9.4c MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings. `master_binding_identifier_conflict` is reached two ways: one identifier carried by several ledgers, and several identifiers each reaching a different ledger. The copy described only the first, so in the second case it sent the operator hunting for a duplicate that does not exist. It now says the identifiers do not agree on one ledger, and names both shapes. The live-catalogue family behaviour is now recorded as `TALLY_PROTOCOL_REFERENCE.md` §9.4c, with the counts staying in `TEST_CORPUS.md` §9.1, and this comment cites the reference rather than the corpus. I had argued the measurement belonged only in the corpus because it is Bridge's behaviour rather than Tally's; the part I had wrong is that "real catalogues carry families a partial name cannot separate" is an observation about live books, and any client matching a supplied name against a read catalogue meets it. Co-Authored-By: Claude Opus 5 --- scripts/source-draft-screen.test.tsx | 2 +- src/SourceDraftScreen.tsx | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index 710220b9..be87dd8a 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -388,7 +388,7 @@ test("names the refusal when the name and the identifier point at different ledg test("distinguishes the two other refusals that are not weak matches", async () => { for (const [reason, phrase] of [ - ["master_binding_identifier_conflict", "appears in more than one existing ledger"], + ["master_binding_identifier_conflict", "do not agree on one existing ledger"], ["master_binding_name_ambiguous", "once case and separators are set aside"], ] as const) { mocks.invoke.mockReset(); diff --git a/src/SourceDraftScreen.tsx b/src/SourceDraftScreen.tsx index d996a689..904c6e11 100644 --- a/src/SourceDraftScreen.tsx +++ b/src/SourceDraftScreen.tsx @@ -127,8 +127,9 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: // and they call for opposite actions. A withheld family is the binder // refusing to print an arbitrary slice of ledgers this name cannot separate // — slicing put the right one out of view about a third of the time across - // sixteen live catalogues, recorded with its counts and scope in - // `docs/tally/TEST_CORPUS.md` §9.1 — and a fuller source name fixes it. Budget exhaustion is + // sixteen live catalogues: `TALLY_PROTOCOL_REFERENCE.md` §9.4c states the + // rule, `TEST_CORPUS.md` §9.1 carries the counts and their scope — and a + // fuller source name fixes it. Budget exhaustion is // this report running out of room on earlier rows; the source name is fine // and nothing the operator writes here would change it. if (binding.unbound_reason === "master_binding_no_discriminating_candidate") { @@ -149,7 +150,11 @@ function catalogRefusalLead(reason: string | null) { case "master_binding_identifier_name_conflict": return "This source name matches one existing ledger exactly, while an identifier inside it matches a different one. They disagree, so nothing is chosen."; case "master_binding_identifier_conflict": - return "An identifier in this source line appears in more than one existing ledger, so it cannot say which."; + // Two different shapes reach this reason: one identifier carried by + // several ledgers, and several identifiers each reaching a different + // ledger. Naming only the first sent the operator hunting for a duplicate + // that does not exist. + return "The identifiers in this source line do not agree on one existing ledger — either one of them appears in several, or they point at different ones."; case "master_binding_name_ambiguous": return "More than one existing ledger carries this name once case and separators are set aside, and nothing measured says which one Tally would pick."; default: From 330bd696b4eb1d8e9621dd5018613bdd101405c2 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 03:36:36 +0530 Subject: [PATCH 32/75] fix(master-binding): the memo missed the path it was written for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The memo added for repeated source names went into `unresolved_status`, which the identifier-conflict and name-ambiguity paths reach. An ordinary near miss does not: its `None` arm called `collect_candidates` directly. So the memo covered two paths and missed the one the cost was reported against — a truncated name against a prefix family matches nothing exactly, by identifier or by the narrow fold, so every entity took the unmemoized route. My own test for that fix used exactly that case and passed, because it asserted the answer rather than the work. A memo that is never consulted returns correct answers all day. `remembered_candidates` is now the only way to reach the search, so a call site cannot forget the memo. And `collect_candidates` increments a test-only counter, so the repeated-name test asserts the search ran **once** for forty rows rather than that the forty answers agree. Reverting the near-miss path to the direct call now gives 40 against an expected 1 — the assertion is an instrument, which is what the first version was not. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 51 ++++++++++++++----- .../src/master_binding_tests.rs | 12 +++++ 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 025eb899..0858b916 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "b11e2696a09d535b9bdc98bc6be30e728dd8761d1e6019dbb12a7c377c90a46b", + "compatibility_surface_sha256": "fa3db3685a45e4f360bffcaec4c783ccdb4c947dd5ddbe1943003d978393024d", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index ec934f8a..d016f581 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "a3a13f59b54853dd6a4d32be9d2fb427f2c02c491eafc5857b3152cbaff6482d" + "sha256": "e36edcd7731c31398417853d96af60667088813421a65d6ff20a4242c05745d6" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "b11e2696a09d535b9bdc98bc6be30e728dd8761d1e6019dbb12a7c377c90a46b" + "manifest_sha256": "fa3db3685a45e4f360bffcaec4c783ccdb4c947dd5ddbe1943003d978393024d" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index cdda96b8..2b0b9bdd 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -986,7 +986,7 @@ fn bind_one( ), None => { let (candidates, masters_found) = - collect_candidates(catalog, entity, &identifier_matches); + remembered_candidates(catalog, entity, &identifier_matches, memo); let reason = if !candidates.is_empty() { UnboundReason::NearMiss } else if masters_found > MAX_PREFIX_FAMILY { @@ -1015,17 +1015,8 @@ fn unresolved_status( budget: &mut usize, memo: &mut CandidateMemo, ) -> BindingStatus { - let memo_key = (entity.key.clone(), identifier_matches.clone()); - let (mut candidates, masters_found) = match memo.get(&memo_key) { - Some(remembered) => remembered.clone(), - None => { - let computed = collect_candidates(catalog, entity, identifier_matches); - if memo.len() < MAX_CANDIDATE_MEMO_ENTRIES { - memo.insert(memo_key, computed.clone()); - } - computed - } - }; + let (mut candidates, masters_found) = + remembered_candidates(catalog, entity, identifier_matches, memo); if let Some(index) = exact { if !candidates.iter().any(|(candidate, _)| *candidate == index) { candidates.push((index, CandidateRule::NormalizedEqual)); @@ -1093,6 +1084,40 @@ fn unresolved_from( } } +/// `collect_candidates` behind its memo, and the only way to reach it. +/// +/// The first version of this memo sat inside `unresolved_status`, which reaches +/// the search for an identifier conflict and for a name ambiguity — but **not** +/// for an ordinary near miss, which is the one case the cost was reported +/// against. Routing one more call site would have fixed that instance and left +/// the next one to be noticed; one entry point makes it structural. +fn remembered_candidates( + catalog: &MasterCatalog, + entity: &SourceEntity, + identifier_matches: &BTreeSet, + memo: &mut CandidateMemo, +) -> (Vec<(usize, CandidateRule)>, usize) { + let key = (entity.key.clone(), identifier_matches.clone()); + if let Some(remembered) = memo.get(&key) { + return remembered.clone(); + } + let computed = collect_candidates(catalog, entity, identifier_matches); + if memo.len() < MAX_CANDIDATE_MEMO_ENTRIES { + memo.insert(key, computed.clone()); + } + computed +} + +// Counts searches that actually ran, so a test can prove the memo is consulted +// rather than assume it. A test asserting only that the answer is right passes +// whether or not the search ran — which is exactly how the near-miss path +// stayed unmemoized through a green suite. +#[cfg(test)] +thread_local! { + pub(crate) static CANDIDATE_SEARCHES: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} + /// Produces every defensible master, each labelled with the rule that surfaced /// it. The strongest rule wins where several apply. Nothing here ranks by /// similarity, and nothing here chooses. @@ -1101,6 +1126,8 @@ fn collect_candidates( entity: &SourceEntity, identifier_matches: &BTreeSet, ) -> (Vec<(usize, CandidateRule)>, usize) { + #[cfg(test)] + CANDIDATE_SEARCHES.with(|count| count.set(count.get() + 1)); // Masters this name reaches by prefix. The key index is ordered, so this is // a range walk rather than a scan of the catalog per entity. let extending = if entity.key.chars().count() >= MIN_PREFIX_KEY_CHARS { diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 537ebbd7..35d8018c 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -843,7 +843,19 @@ fn repeating_one_source_name_does_not_repeat_the_search_or_change_the_answer() { let repeated = (0..40) .map(|position| SourceEntity::new(position, "Acme Branch").expect("valid")) .collect::>(); + // Counted, not assumed. The first version of this memo was consulted on the + // conflict and ambiguity paths but not on the ordinary near miss — which is + // the case this test uses — and every assertion below still passed, because + // they check the answer rather than the work. `Acme Branch` matches no + // master exactly, by identifier or by the narrow fold, so all forty entities + // take that path. + super::CANDIDATE_SEARCHES.with(|count| count.set(0)); let report = bound(&catalog, &repeated); + assert_eq!( + super::CANDIDATE_SEARCHES.with(std::cell::Cell::get), + 1, + "forty rows naming one ledger ran the candidate search more than once" + ); assert_eq!(report.totals().requested, 40); assert_eq!(report.totals().bound, 0); for entity in report.entities() { From 1ba70e85d32b22ee93e89a2ae3df82c78ea4a404 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 04:00:50 +0530 Subject: [PATCH 33/75] docs(corpus): the narrowed fold's live row, re-run and confirmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The end-to-end slice was recorded with one row PENDING: the separator row had been run before the resolving fold was narrowed, and showed the bind the narrowing removed. The lab endpoint went down before it could be repeated. It is back, and the re-run confirms it. Same ten names, same company, same catalogue — `catalogue_evidence_sha256` 0767077c on both runs, so the book did not move underneath the comparison. `MB-PILOT-ALPHA-(5550001001)` now returns a near-miss carrying `MB PILOT ALPHA (5550001001)` as its sole candidate under `normalized_equal`, and reports the code it could not resolve on. The other nine rows returned identically. That is the two-fold split in a single row, against a real instance: the looser fold still reaches the master and no longer answers for it. §9.2 is VERIFIED for all ten rows at 330bd696, and the confidence table no longer carries the exception. Co-Authored-By: Claude Opus 5 --- docs/tally/TEST_CORPUS.md | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/tally/TEST_CORPUS.md b/docs/tally/TEST_CORPUS.md index d6adf645..378f6e3e 100644 --- a/docs/tally/TEST_CORPUS.md +++ b/docs/tally/TEST_CORPUS.md @@ -359,7 +359,7 @@ them is **PARTIAL**. Scope of each, so neither is read for more than it covers: | The ten ledgers exist in that company and nowhere else | **VERIFIED** | `CREATED=10, ALTERED=0, ERRORS=0`, then a readback of the ledger list naming all ten, plus a readback of a guard company showing none | | No book carried an embedded **numeric** identifier before this | **VERIFIED** | all 16 loaded companies read through the `StandardLedgerCatalogV1` request, responses written to files and parsed from the files; 470 names, **0 numeric**. Exactly **one** name yielded a *code* identifier, so the absence is of the numeric shape only — stated narrowly because these counts are what justify the rule's strictness | | The identifier rule behaves correctly against live-read names | **PARTIAL** | exercised against these ten seeded names only, on one instance, one licence tier, one Tally build. Fabricated *source* names against live *catalogue* names — no real source document has been bound end to end | -| The shipped consumer path runs end to end against a real instance | **VERIFIED 2026-09-11**, nine rows of ten | the branch's own `bridge_mcp` binary driven over stdio against licensed TallyPrime 7.1 Silver, reading this company's catalogue over the wire and returning the binder's report. One row is **PENDING** a re-run after the fold was narrowed — see "The end-to-end slice" below | +| The shipped consumer path runs end to end against a real instance | **VERIFIED 2026-09-11**, all ten rows | the branch's own `bridge_mcp` binary driven over stdio against licensed TallyPrime 7.1 Silver, reading this company's catalogue over the wire and returning the binder's report. Run twice — before and after the resolving fold was narrowed — against the same catalogue digest; see §9.2 | | Binding is safe on catalogues generally | **UNVERIFIED** | one company, one instance, one Tally build, and every *source* name fabricated. No engagement has run a real document through this path; the mutation sweep is fabricated mutations of live names, not observed operator input | **Added 2026-09-10.** Ten ledgers prefixed `MB `, seeded so the master-binding @@ -435,7 +435,8 @@ the failure it was written for; it does not say candidate lists are sufficient i surface that ships it: the crate had tests, and the catalogue side had live coverage, but no run had gone request-to-report through the consumer. This one does. -**Procedure.** `cargo build --bin bridge_mcp` at commit `9e34cd77`, then the binary driven +**Procedure.** `cargo build --bin bridge_mcp`, first at commit `9e34cd77` and again at +`330bd696` after the resolving fold was narrowed, then the binary driven over stdio with a real MCP session — `initialize`, `notifications/initialized`, `tools/call validate_masters` — against `http://127.0.0.1:9001`, TallyPrime 7.1, licence tier **silver**, `education_mode=false`. The tool read this company's ledger catalogue over the wire @@ -447,7 +448,7 @@ names**. | --- | --- | --- | | `MB PILOT ALPHA (5550001001)` | `exact` | byte equality | | `mb pilot alpha (5550001001)` | `identifier` | the identifier is consulted **before** the name, so a case variant never reached the fold | -| `MB-PILOT-ALPHA-(5550001001)` | `normalized` | the separator fold — **since narrowed**, see below | +| `MB-PILOT-ALPHA-(5550001001)` | `near_miss`, 1 candidate, `normalized_equal` | the unverified separator direction **suggests and does not resolve** — it returned `normalized` before the fold was narrowed | | `Alpha Pilot Account 5550001001` | `identifier` | **the rule this module exists for**: a name sharing no word with the master bound on its embedded number | | `Zeta Holdings 5550001009` | `near_miss`, 2 candidates, `shared_identifier` | one identifier on two masters refuses and shows both | | `MB TRADING COMPANY LTD` | `near_miss`, 2 candidates | a truncation surfaces both neighbours and chooses neither | @@ -467,20 +468,23 @@ tier. Every *source* name is fabricated — a real source document has still nev nothing here speaks to how operator-written names actually differ from master names. It exercises the MCP consumer; the desktop consumer shares the crate but was not driven. -**It found a defect, which is the reason to run these.** The third row bound +**It found a defect, which is the reason to run these.** On the first run the third row *bound* `MB-PILOT-ALPHA-(5550001001)` to a master carrying spaces. That is the **reverse** of the direction `TALLY_PROTOCOL_REFERENCE.md` §9.4b measured, and §9.4b marks it **UNVERIFIED** — so the binder was resolving on evidence that does not support resolving, which is exactly what -§9.4b exists to prevent. The resolving fold has since been narrowed to the three verified -transformations, and that row now returns a near-miss carrying `MB PILOT ALPHA (5550001001)` as -its sole candidate. ADR 0016 §3 records the narrowing and its cost. - -**One row of this table is therefore owed a re-run.** The nine other rows are unaffected by the -narrowing — they turn on identifiers, exact equality or refusal — and their unit coverage is -unchanged. Row three's new behaviour is covered by -`a_source_space_matches_a_master_hyphen_and_only_that_direction` but has not itself been seen on -a live instance: the lab endpoint went down before the re-run. Read this table as VERIFIED for -nine rows and PENDING for one, not as ten. +§9.4b exists to prevent. Nothing in the unit suite said so, because the suite encoded the same +assumption the implementation did. + +**The second run, after the narrowing, is what closes it.** Same ten names, same company, same +catalogue — `catalogue_evidence_sha256` `0767077c…` on both runs, so the book did not move +underneath the comparison. Row three now returns a near-miss carrying +`MB PILOT ALPHA (5550001001)` as its **sole** candidate under `normalized_equal`, and reports the +code it could not resolve on. That is the whole intent of the two-fold split in one row: the +looser fold still reaches the master, and no longer answers for it. The other nine rows returned +identically across both runs. + +So this table is VERIFIED for all ten rows, at `330bd696`. ADR 0016 §3 records the narrowing and +its cost. **What it found within minutes.** The `DELTA`/`EPSILON` pair exposed a defect no fabricated fixture had produced: a *byte-exact* request for `MB PARTY DELTA (5550001009)` was being From 0456b28915aaf0c19df891319865f18b70c68164 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 04:47:49 +0530 Subject: [PATCH 34/75] fix(master-binding): measure the SKU, and widen the fold to what it folds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewer pointed out that §9.4b is scoped to **Edit Log 7.0 Educational** and marks licensed TallyPrime UNVERIFIED, while nothing in this module carried a profile to enforce that with — so every normalized bind rested on a measurement from a different SKU. True, and nobody had said it. The remedy they offered was to gate the fold on a qualified profile. With the owner's authorisation I measured the profile instead, using §9.4b's own method: import a voucher naming a folded spelling, and let Tally answer. Twelve variants against licensed 7.1 silver, then the **day book read back** to record which master each voucher actually posted against — one reported ALTERED where CREATED was expected, so the counters alone would have been a guess. All eight created vouchers were deleted by REMOTEID and the day read back empty. Licensed 7.1 folds **more** than the Educational scope allowed anyone to claim. Space, `-` and `/` are one separator in both directions; internal runs collapse; surrounding whitespace is ignored; ASCII case folds. An en dash, an underscore, `AND` for `&` and an NFD spelling are all rejected. So the narrowing earlier in this PR was right against §9.4b and wrong against the SKU. The resolving fold is symmetric again, one key per side, and the asymmetric index it needed is deleted. 600 of 995 mutations bind, against 420 under the narrow fold, and `X - Y` resolves again. **The two rules that matter are negative and neither is guessable.** An en dash and an underscore look like separators and are not, so the set is written out rather than described — a mutation replacing it with `is_ascii_punctuation()` fails two tests. Canonical equivalence stays refused, re-confirmed on this SKU. Recorded as TALLY_PROTOCOL_REFERENCE.md §9.4d with the method, the deletion and the scope. §9.4b's rows now **cite** it rather than absorb it: those rows are about a different instance, and folding a licensed result into them would silently widen a measurement nobody repeated there. Its one row grouping underscore, en dash and `/` is split, because on this SKU they do not agree. ADR 0016 §3 records being wrong in both directions. The lesson is neither fold less nor fold more: the scope line of an inherited measurement is part of the measurement. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 123 +++++----- docs/tally/TALLY_PROTOCOL_REFERENCE.md | 70 +++++- docs/tally/TEST_CORPUS.md | 14 ++ .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 +- .../bridge-tally-core/src/master_binding.rs | 97 ++++---- .../src/master_binding_tests.rs | 211 ++++++++++-------- 7 files changed, 293 insertions(+), 230 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index ec2bec34..5983a789 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -205,77 +205,58 @@ exactly one master shares it. **There are two folds, and which one may answer is the whole of this section.** -`TALLY_PROTOCOL_REFERENCE.md` §9.4b sent named variants at a live master and -recorded which Tally accepted. Exactly three: ASCII case folding, one trailing -space, and a **space supplied where the master carries a hyphen**. `AND` for -`&`, a missing suffix word and a singular for a plural were rejected. §9.4b -marks everything else UNVERIFIED and states the rule this section now follows — -*a fold is only as safe as its least-verified step, and a looser fold may -**suggest**, never resolve.* - -- The **narrow fold** resolves. It implements those three and nothing else. The - hyphen step is directional, because the measurement was: a source **space** - was sent at a master **hyphen**, and the reverse was never sent. A symmetric - key cannot express a direction, so the master side of the index answers to - both its own spelling and its hyphens-as-spaces, while the source side answers - only to its own. A source hyphen therefore finds no master space. -- The **wide fold** suggests. It carries the reverse hyphen direction, collapsed - whitespace runs, leading whitespace and the Unicode dash variants — and - everything it reaches is offered as a `NormalizedEqual` candidate for a human - to confirm. - -**One transformation is not merely unverified — it is measured wrong, and it is -the one that nearly slipped through.** Canonical equivalence looks like decoding -rather than folding: NFC and NFD spell the same characters, and no operator can -type them differently on purpose. But Tally stores a master name as the bytes -that created it and matches on exact codepoints. A voucher naming a UI-created -ledger in its canonically equivalent NFD spelling was **rejected** — -`EXCEPTIONS=1`, `LINEERROR`, ledger does not exist — while the NFC spelling -created it (measured 2026-08-19, TallyPrime 7.1). So they are different masters -to Tally, and folding them here would resolve a source name onto a master Tally -itself keeps apart. NFC stays in the wide fold, where it can only suggest. - -The general lesson is worth more than the case: **a step that reads like -decoding deserves the same evidence as a step that reads like folding.** This -one survived two reviews of the fold by not looking like part of it. - -**Neither side is trimmed.** An earlier draft of this section said -`SourceEntity` trims what the document gave it. It does not — `validated_name` -bounds a name and returns it unchanged, and a master is retained byte for byte -because a caller writes it back. The claim was written from what seemed -reasonable rather than from the constructor, and a contract that describes -behaviour the implementation does not have is worse than no contract: a consumer -following it expects a bind and gets a candidate. - -What actually happens is narrower, and directional. **One trailing space is -dropped from the source key, and from nothing else** — because that is the -shape §9.4b measured: a name carrying a trailing space was *supplied* against a -clean live master and Tally matched it. So `"Alpha Traders "` resolves to a -live `Alpha Traders`, while a master spelled `"Alpha Traders "` does not resolve -from a clean source name; it is offered as a candidate. Leading whitespace is -unverified in both directions and is dropped from neither. - -**This was got wrong first, and the correction is the useful record.** An -earlier version of this ADR claimed the fold "stops exactly where Tally stops" -while the implementation resolved on four transformations §9.4b marks -UNVERIFIED. It read naturally, which is exactly the skimming-implementer failure -§9.4b was written to prevent, and the live slice in `TEST_CORPUS.md` §9 caught -it binding that way against a real instance. - -**The cost is real and is stated here rather than discovered later.** `X - Y` is -a common ledger convention — six of the seventeen hyphenated names in the -observed books take that shape — and reaching it from `X Y` needs the measured -hyphen step *and* a whitespace run collapsed. So those no longer resolve. On the -fabricated mutation book, 420 of 995 mutations bind where most once did. - -**What makes that a trade and not a loss** is measured alongside it: every -mutation the wide fold would have resolved is still shown, as a candidate -carrying the right master. The sweep asserts it case by case rather than as a -percentage. So narrowing the fold costs a confirmation, never a search — which -is the trade §9.4b prescribes and the same one §4 makes for every other -near-miss in this module. A binder that answers from unverified evidence has not -saved the operator a step; it has moved the step to wherever the wrong posting -is found. +The resolving fold implements exactly the equivalences +`TALLY_PROTOCOL_REFERENCE.md` §9.4d measured on **licensed TallyPrime 7.1** — +the SKU this writes to — by naming each spelling in a voucher and reading the +day book back to see which master it reached: + +- ASCII case folds; +- leading and trailing whitespace is ignored; +- an internal run of spaces collapses; +- **space, `-` and `/` are one separator**, in both directions. + +Everything else is exact on codepoints. The wide fold (`master_identity_key`) +carries more than that and may only offer candidates. + +**The two rules that matter are negative, and neither is guessable.** An **en +dash** and an **underscore** were sent and *rejected*: they are not separators +to Tally however much they look like ones, so a fold that treats "punctuation" +or "separators" as a class is wider than the gateway and merges masters it keeps +apart. And **canonical equivalence is not folded** — an NFD spelling of an NFC +master is a different master, consistent with the exact-codepoint finding +recorded against this release. + +**This section has been wrong twice, in both directions, and the record is +worth more than the conclusion.** + +It first claimed the fold "stops exactly where Tally stops" while resolving on +four transformations §9.4b marked UNVERIFIED — a Bridge guess wearing Tally's +authority. That was corrected by narrowing to the three §9.4b had measured, +which cost 420 of 995 mutation binds and withdrew `X - Y`, a common ledger +convention. + +Then the narrowing turned out to be over-strict, because §9.4b's scope is *Edit +Log 7.0 Educational* and this project writes to licensed 7.1. Measuring that SKU +directly (§9.4d) found the gateway wider: the reverse hyphen direction, leading +whitespace, collapsed runs and slash all match. The fold is symmetric again, one +key per side, and the asymmetric index the narrow version needed is gone. On the +mutation book **600 of 995** now bind, against 420 under the narrow fold. + +The lesson is not "measure more". It is that **the scope line of an inherited +measurement is part of the measurement**: §9.4b was accurate and its scope was +the thing being skipped, by me in one direction and then by the narrowing in the +other. + +**What still holds regardless of which way the evidence moves.** A fold that +merges two **live** masters never resolves — the pair is an ambiguity and both +surface (§4). Two masters differing only in case, trailing whitespace or +separator style collapse under the measured fold and are refused there, which is +also what Tally implies, since it would match that source name to either. + +**Trimming.** Neither side is trimmed on the way in: `validated_name` bounds a +name and returns it unchanged, and an observed master is retained byte for byte +because a caller writes it back. Whitespace is handled by the fold, not by +editing the stored text. This fold is deliberately **separate from the general comparison key**, which is shared with other contracts for voucher numbers and voucher-type names. §9.4b diff --git a/docs/tally/TALLY_PROTOCOL_REFERENCE.md b/docs/tally/TALLY_PROTOCOL_REFERENCE.md index c543fa29..7590527c 100644 --- a/docs/tally/TALLY_PROTOCOL_REFERENCE.md +++ b/docs/tally/TALLY_PROTOCOL_REFERENCE.md @@ -942,12 +942,17 @@ names Tally keeps apart — which posts to the wrong account, silently. | --- | --- | | ASCII case folding | **VERIFIED** — lowercase matched | | supplying a **space** where the master has a **hyphen** | **VERIFIED** — `BRIDGE PROBE LEDGER A` matched `BRIDGE-PROBE-LEDGER-A` | -| supplying a **hyphen** where the master has a **space** | **UNVERIFIED** — the reverse direction was never sent | +| supplying a **hyphen** where the master has a **space** | **UNVERIFIED here** — the reverse direction was never sent on this SKU. Measured **matched** on licensed 7.1, §9.4d | | one trailing space ignored | **VERIFIED** | -| *leading* whitespace ignored | **UNVERIFIED** | -| runs of internal whitespace collapsed to one | **UNVERIFIED** — only a single space was tested | +| *leading* whitespace ignored | **UNVERIFIED here**. Measured **matched** on licensed 7.1, §9.4d | +| runs of internal whitespace collapsed to one | **UNVERIFIED here** — only a single space was tested. Measured **matched** on licensed 7.1, §9.4d | | non-ASCII case folding (Devanagari, Tamil, Bengali, Turkish dotted I) | **UNVERIFIED** | -| any other separator (underscore, en dash, `/`) treated as a space | **UNVERIFIED** | +| any other separator (underscore, en dash, `/`) treated as a space | **UNVERIFIED here**, and §9.4d splits it on licensed 7.1: `/` **matched**, underscore and en dash **rejected**. Not one row — do not fold them together | + +**A wider result exists for a different SKU.** §9.4d re-ran this measurement on **licensed +TallyPrime 7.1** and found the gateway folds more than these rows establish. It is a separate +section on purpose: these rows are about Edit Log 7.0 Educational, and absorbing a licensed-Silver +result into them would silently widen the scope of a measurement nobody repeated here. A fold implementing only the verified three is safe in the direction that matters: it may *fail to match* a pair Tally would accept, which surfaces as a refusal a human sees. Adding the unverified @@ -1013,6 +1018,63 @@ Whether stock items, groups and voucher types match by the same rule is UNVERIFI says nothing about voucher numbers — a fold shared between master names and voucher numbers is assuming something nobody has measured. +### 9.4d Master-name matching on **licensed** TallyPrime 7.1 + +**VERIFIED 2026-09-12**, and it widens §9.4b rather than confirming it. §9.4b is inherited from a +2026-07-30 measurement on **Edit Log 7.0 Educational** and marks licensed TallyPrime UNVERIFIED. +This is that measurement re-run on the SKU this project actually writes to: **TallyPrime 7.1, +licence tier silver, `education_mode=false`**, ledgers, one lab company. + +**Method is §9.4b's own.** Import a voucher naming a folded spelling of a ledger that exists, and +let Tally answer: a created voucher means the name resolved, a `LINEERROR` naming that ledger +means it did not. Twelve variants, one voucher each, then the **day book was read back** to record +which master each voucher actually posted against — the counters alone would not have said. All +eight created vouchers were then deleted by `REMOTEID` and the day read back empty. + +| Supplied against a live master | Licensed 7.1 | §9.4b on Educational | +| --- | --- | --- | +| exact | **matched** | matched | +| ASCII lowercase | **matched** | matched | +| one trailing space | **matched** | matched | +| a **space** where the master has a **hyphen** | **matched** | matched | +| a **hyphen** where the master has a **space** | **matched** | *UNVERIFIED* | +| leading whitespace | **matched** | *UNVERIFIED* | +| an internal whitespace run collapsed | **matched** | *UNVERIFIED* | +| a **slash** where the master has a **space** | **matched** | not sent | +| an **en dash** where the master has a space | **rejected** | *UNVERIFIED* | +| an **underscore** where the master has a space | **rejected** | *UNVERIFIED* | +| `AND` for `&` | **rejected** | rejected | +| **NFD** against an NFC master | **rejected** | not sent | + +**What this says.** On licensed 7.1, Tally treats **space, hyphen and slash** as interchangeable +separators, collapses internal whitespace runs, ignores leading and trailing whitespace, folds +**ASCII** case, and is otherwise **exact on codepoints**. + +> **RULE: separators fold, and the set is `space`, `-`, `/` — nothing else.** An en dash and an +> underscore are ordinary characters to Tally and are **not** separators, so a fold that treats +> "punctuation" or "separators" as a class is wider than the gateway and will merge masters it +> keeps apart. + +That is the trap §9.4b warned about, arriving from the other side: the danger was never only that +a reader would fold too much, it was that "normalises separators" names no particular set. Two of +the four separators tested are folded and two are not, and nothing about their appearance predicts +which. + +**Canonical equivalence is still refused**, consistent with the exact-codepoint finding recorded +elsewhere in this document: an NFD spelling of an NFC ledger does not resolve. A fold that +normalises before comparing merges masters this gateway keeps apart. + +**Scope.** One instance, one build, one licence tier, **ledgers only**, one company, and the +measurement is of *import-time* name resolution — not collection filters, not stock items, groups +or voucher types, and not voucher numbers. §9.4b's Educational scope stands as its own row; this +does not retire it, and where the two disagree they disagree about different SKUs rather than +about the same one. + +**Fixtures.** `MB-PROBE-LEDGER-A` and `MB CAFÉ PROBE` remain in `BRIDGE CORPUS OPENING` under +`Suspense A/c`, carrying no balances, so this is repeatable. They post-date the catalogue digest +recorded in `TEST_CORPUS.md` §9.2. + + ### 9.4c Real catalogues carry families a partial name cannot separate **VERIFIED 2026-09-10** for the counts, across 16 loaded companies on both lab instances; the rule diff --git a/docs/tally/TEST_CORPUS.md b/docs/tally/TEST_CORPUS.md index 378f6e3e..66f63494 100644 --- a/docs/tally/TEST_CORPUS.md +++ b/docs/tally/TEST_CORPUS.md @@ -468,6 +468,13 @@ tier. Every *source* name is fabricated — a real source document has still nev nothing here speaks to how operator-written names actually differ from master names. It exercises the MCP consumer; the desktop consumer shares the crate but was not driven. +**Superseded in part, 2026-09-12.** The separator row below was read as a defect and drove a +narrowing of the resolving fold. `TALLY_PROTOCOL_REFERENCE.md` §9.4d then measured that same +equivalence directly on **licensed** TallyPrime 7.1 and found Tally does accept it — along with +leading whitespace, collapsed runs and a slash — so the fold was widened back to what the gateway +actually does. Read the paragraph below as the history it is: the row's `normalized` result was +right, and the reasoning that called it wrong was working from §9.4b's Educational scope. + **It found a defect, which is the reason to run these.** On the first run the third row *bound* `MB-PILOT-ALPHA-(5550001001)` to a master carrying spaces. That is the **reverse** of the direction `TALLY_PROTOCOL_REFERENCE.md` §9.4b measured, and §9.4b marks it **UNVERIFIED** — so @@ -486,6 +493,13 @@ identically across both runs. So this table is VERIFIED for all ten rows, at `330bd696`. ADR 0016 §3 records the narrowing and its cost. +**The catalogue digest has since moved, and a mismatch is not drift.** Qualifying §9.4d needed two +ledgers — `MB-PROBE-LEDGER-A` and `MB CAFÉ PROBE` — and they remain in this company under +`Suspense A/c` carrying no balances, so that the measurement is repeatable. Both post-date the +runs above, so `catalogue_evidence_sha256` for `BRIDGE CORPUS OPENING` no longer equals +`0767077c…`. Anyone re-running this slice should expect a different digest and check the ledger +list before treating it as the book changing underneath them. + **What it found within minutes.** The `DELTA`/`EPSILON` pair exposed a defect no fabricated fixture had produced: a *byte-exact* request for `MB PARTY DELTA (5550001009)` was being refused as `IdentifierConflict`, because the number in its name is shared. That made the diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 0858b916..07082a5c 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "fa3db3685a45e4f360bffcaec4c783ccdb4c947dd5ddbe1943003d978393024d", + "compatibility_surface_sha256": "341db3ab3171a958ab89788ca18531e7360233f4fb7b2ed7048023f40ac0fbac", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index d016f581..fef2d752 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -35,7 +35,7 @@ }, { "path": "docs/tally/TALLY_PROTOCOL_REFERENCE.md", - "sha256": "94d94259b2a6c7ae04ad087678a1058cf75eca4def1e2c6cf33405f6a7f16980" + "sha256": "191af28217f598d396ecf7743fe3e18c9ac769b42380ec58a2a6c24b7e5859fa" }, { "path": "docs/tally/compatibility/README.md", @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "e36edcd7731c31398417853d96af60667088813421a65d6ff20a4242c05745d6" + "sha256": "d244fc6df82b4d7b04fb776c130594c42686483bcb4ed650d7806890179b5bdb" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "fa3db3685a45e4f360bffcaec4c783ccdb4c947dd5ddbe1943003d978393024d" + "manifest_sha256": "341db3ab3171a958ab89788ca18531e7360233f4fb7b2ed7048023f40ac0fbac" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 2b0b9bdd..15ac1d91 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -643,7 +643,7 @@ impl SourceEntity { Ok(Self { position, key: master_identity_key(&name), - binding_key: source_binding_key(&name), + binding_key: verified_fold(&name), name, identifiers, }) @@ -745,9 +745,10 @@ impl MasterCatalog { let mut by_token: BTreeMap> = BTreeMap::new(); for (index, entry) in entries.iter().enumerate() { by_key.entry(entry.key.clone()).or_default().push(index); - for binding_key in master_binding_keys(&entry.name) { - by_binding_key.entry(binding_key).or_default().push(index); - } + by_binding_key + .entry(verified_fold(&entry.name)) + .or_default() + .push(index); for identifier in &entry.identifiers { by_identifier .entry(identifier.clone()) @@ -1322,60 +1323,52 @@ fn master_identity_key(value: &str) -> String { .join(" ") } -/// The fold that may **resolve** a name to a master, held to exactly what -/// `TALLY_PROTOCOL_REFERENCE.md` §9.4b measured Tally doing. +/// The fold that may **resolve** a name to a master: exactly the equivalences +/// `TALLY_PROTOCOL_REFERENCE.md` §9.4d measured on the SKU this writes to. /// -/// Three transformations were verified: ASCII case folding, one trailing space -/// ignored, and a **space supplied where the master carries a hyphen**. That -/// last one is directional — `BRIDGE PROBE LEDGER A` was sent against a live -/// `BRIDGE-PROBE-LEDGER-A`, and the reverse was never sent — so it cannot be a -/// symmetric replacement in a shared key. It lives in `master_binding_keys`, -/// on the master side only, which is the side the evidence is about. +/// §9.4b measured Edit Log 7.0 Educational and marked most of this UNVERIFIED, +/// so an earlier version of this module resolved on three transformations only +/// and offered the rest as candidates. §9.4d re-ran that measurement on +/// **licensed TallyPrime 7.1**, read the day book back to see which master each +/// name actually reached, and found the gateway wider than the Educational +/// scope allowed anyone to claim: /// -/// **Canonical equivalence is not folded here, and that one is measured rather -/// than merely unverified.** A voucher naming a UI-created `Cafe\u{301}...` -/// ledger in its canonically equivalent NFD spelling was rejected — -/// `EXCEPTIONS=1`, `LINEERROR`, ledger does not exist — while the NFC spelling -/// created it. Tally stores a master name as the bytes that made it and matches -/// on exact codepoints, so NFC and NFD spellings are *different masters*. -/// Folding them together here would resolve a source name onto a master Tally -/// itself keeps apart. It reads like decoding rather than folding, which is -/// exactly why it nearly stayed. +/// - ASCII case folds; +/// - leading and trailing whitespace is ignored; +/// - an internal run of spaces collapses; +/// - **space, `-` and `/` are one separator**, in both directions. /// -/// Every other unverified step — the reverse hyphen direction, collapsed -/// whitespace runs, leading whitespace, Unicode dash variants, non-ASCII case — -/// is deliberately absent too. None is lost: `master_identity_key` carries them -/// all, and everything it reaches is offered as a candidate. -fn verified_fold(value: &str) -> String { - value.to_ascii_lowercase() -} - -/// The key a **source** name is looked up by. +/// Everything else is exact on codepoints. So the two rules that matter are +/// both negative, and neither is guessable from appearance: /// -/// One trailing space is dropped here and nowhere else, because that is how it -/// was measured: §9.4b *supplied* a name carrying a trailing space against a -/// clean live master and Tally matched it. The reverse — a master carrying a -/// trailing space, reached from a clean source name — was never sent, and -/// stripping on the master side quietly asserted it. Same directional trap as -/// the hyphen, one row further down the same table. -fn source_binding_key(value: &str) -> String { - verified_fold(value.strip_suffix(' ').unwrap_or(value)) -} - -/// The keys a **master** name answers to. +/// **An en dash and an underscore are not separators.** They were sent and +/// rejected. A fold that treats "punctuation" or "separators" as a class is +/// wider than the gateway and merges masters Tally keeps apart — which is why +/// the separator set here is written out rather than described. /// -/// Its own, and — because a source space was measured matching a master hyphen -/// — the same name with its hyphens read as spaces. Offering the second from -/// the master side is what keeps the measured direction measured: a source -/// hyphen finds no master space, while a source space finds a master hyphen. +/// **Canonical equivalence is not folded.** An NFD spelling of an NFC master +/// was rejected here too, consistent with the exact-codepoint finding recorded +/// against this same release. Normalizing before comparing would resolve a name +/// onto a master the gateway keeps apart. It reads like decoding rather than +/// folding, which is how it survived two audits of this function. /// -/// Two masters that answer to one key are an ambiguity and are refused there, -/// which is the same answer Tally's own behaviour implies: it would match that -/// source name to both. -fn master_binding_keys(value: &str) -> BTreeSet { - let base = verified_fold(value); - let hyphens_as_spaces = base.replace('-', " "); - BTreeSet::from([base, hyphens_as_spaces]) +/// Both hyphen directions are measured now, so this is symmetric and one key +/// per side is enough — the asymmetric index an earlier version needed is gone. +fn verified_fold(value: &str) -> String { + value + .chars() + .map(|character| match character { + '-' | '/' => ' ', + other => other.to_ascii_lowercase(), + }) + .collect::() + // ASCII space only. A tab and a no-break space were never sent, so they + // stay ordinary characters rather than joining the separator set on the + // strength of looking like whitespace. + .split(' ') + .filter(|part| !part.is_empty()) + .collect::>() + .join(" ") } /// Splits a comparison key into words. diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 35d8018c..40721982 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -84,35 +84,40 @@ fn unusable_names_are_refused_at_the_boundary() { } #[test] -fn a_trailing_space_is_dropped_on_the_side_that_was_measured() { - // §9.4b *supplied* a name carrying a trailing space against a clean live - // master, and Tally matched it. That direction resolves. - let clean = ledgers(&["Alpha Traders", "Beta Supply"]); - let binding = bind_one_name(&clean, "Alpha Traders "); - assert_eq!(binding.bound_name(), Some("Alpha Traders")); - assert_eq!( - binding.source_name, "Alpha Traders ", - "a source name is recorded as the document wrote it, not as the fold read it" - ); - - // The reverse was never sent. Stripping on the master side asserted it - // silently — the same directional trap as the hyphen, one row down the same - // table — so a master carrying a trailing space is now a candidate. - let trailing = ledgers(&["Alpha Traders ", "Beta Supply"]); - assert_eq!(trailing.names().next(), Some("Alpha Traders ")); - let binding = bind_one_name(&trailing, "Alpha Traders"); - assert_eq!(binding.bound_name(), None); - assert_eq!(candidate_names(&binding), ["Alpha Traders "]); +fn surrounding_and_repeated_whitespace_is_folded_on_both_sides() { + // §9.4d: leading whitespace, one trailing space and a collapsed internal + // run all matched on licensed 7.1, in both directions. + for (master, source) in [ + ("Alpha Traders", "Alpha Traders "), + ("Alpha Traders ", "Alpha Traders"), + ("Alpha Traders", " Alpha Traders"), + (" Alpha Traders", "Alpha Traders"), + ("Alpha Traders", "Alpha Traders"), + ("Alpha Traders", "Alpha Traders"), + ] { + let catalog = ledgers(&[master, "Beta Supply"]); + assert_eq!( + bind_one_name(&catalog, source).bound_name(), + Some(master), + "{source:?} did not reach {master:?}" + ); + } - // Leading whitespace is unverified in both directions, and is not trimmed - // from either side. A caller writes the bound name back byte for byte, so - // an observed name is never tidied. - let leading = ledgers(&[" Alpha Traders", "Beta Supply"]); - let binding = bind_one_name(&leading, "Alpha Traders"); - assert_eq!(binding.bound_name(), None); - assert_eq!(candidate_names(&binding), [" Alpha Traders"]); - let binding = bind_one_name(&clean, " Alpha Traders"); - assert_eq!(binding.bound_name(), None); + // An observed name is still retained byte for byte: a caller writes it back. + let catalog = ledgers(&["Alpha Traders ", "Beta Supply"]); + assert_eq!(catalog.names().next(), Some("Alpha Traders ")); + let binding = bind_one_name(&catalog, "Alpha Traders"); + assert_eq!(binding.bound_name(), Some("Alpha Traders ")); + assert_eq!( + binding.source_name, "Alpha Traders", + "a source name is recorded as the document wrote it" + ); + + // ASCII space only. A no-break space was never sent, so it stays an + // ordinary character rather than joining the separator set on the strength + // of looking like whitespace. + let nbsp = ledgers(&["Alpha\u{a0}Traders", "Beta Supply"]); + assert_eq!(bind_one_name( , "Alpha Traders").bound_name(), None); } #[test] @@ -133,20 +138,21 @@ fn near_identical_masters_are_an_ambiguity_where_they_collide_and_never_a_refuse assert_eq!(reason(&other), UnboundReason::NameAmbiguous); assert_eq!(candidate_names(&other), ["Alpha Traders", "alpha traders"]); - // A trailing space no longer collides, because it is dropped only on the - // side that was measured. The catalog is still accepted, and each master is - // reachable — the clean one from a source carrying the space, the other - // only byte-exactly. + // Two masters differing only in trailing whitespace collapse under the + // measured fold too, so they are an ambiguity rather than a refused + // catalog — and byte equality still picks one where the source has it. let spaced = ledgers(&["Alpha Traders", "Alpha Traders "]); - assert_eq!( - bind_one_name(&spaced, "alpha traders ").bound_name(), - Some("Alpha Traders") - ); assert_eq!( bind_one_name(&spaced, "Alpha Traders ").bound_name(), Some("Alpha Traders "), "byte equality outranks the fold" ); + let ambiguous = bind_one_name(&spaced, "alpha traders"); + assert_eq!(reason(&ambiguous), UnboundReason::NameAmbiguous); + assert_eq!( + candidate_names(&ambiguous), + ["Alpha Traders", "Alpha Traders "] + ); } #[test] @@ -473,36 +479,50 @@ fn a_decisive_identifier_pointing_elsewhere_still_outranks_a_byte_exact_name() { } #[test] -fn a_source_space_matches_a_master_hyphen_and_only_that_direction() { - // `TALLY_PROTOCOL_REFERENCE.md` §9.4b sent `BRIDGE PROBE LEDGER A` at a live - // `BRIDGE-PROBE-LEDGER-A` and Tally matched it. That is the whole of the - // measurement: one separator, one direction. - let hyphenated = ledgers(&["BRIDGE-PROBE-LEDGER-A", "Beta Supply"]); +fn space_hyphen_and_slash_are_one_separator_in_both_directions() { + // `TALLY_PROTOCOL_REFERENCE.md` §9.4d, measured on licensed TallyPrime 7.1 + // by naming each spelling in a voucher and reading the day book back to see + // which master it reached. Both hyphen directions matched, and so did a + // slash — so this fold is symmetric, and one key per side is enough. + for (master, source) in [ + ("BRIDGE-PROBE-LEDGER-A", "BRIDGE PROBE LEDGER A"), + ("BRIDGE PROBE LEDGER A", "BRIDGE-PROBE-LEDGER-A"), + ("BRIDGE PROBE LEDGER A", "BRIDGE/PROBE/LEDGER/A"), + ("BRIDGE/PROBE/LEDGER/A", "BRIDGE-PROBE-LEDGER-A"), + ] { + let catalog = ledgers(&[master, "Beta Supply"]); + assert_eq!( + bind_one_name(&catalog, source).bound_name(), + Some(master), + "{source} did not reach {master}" + ); + } + + // `X - Y` is a common ledger convention, and it needs the separator step + // and the whitespace-run step together. Both are measured, so it resolves. + let spaced_hyphen = ledgers(&["Bank - HDFC Current", "Beta Supply"]); assert_eq!( - bind_one_name(&hyphenated, "BRIDGE PROBE LEDGER A").status, - BindingStatus::Bound { - catalog_name: "BRIDGE-PROBE-LEDGER-A".to_string(), - basis: BindingBasis::NormalizedName, - } + bind_one_name(&spaced_hyphen, "Bank HDFC Current").bound_name(), + Some("Bank - HDFC Current") ); - // The reverse was never sent, and §9.4b marks it UNVERIFIED. A symmetric - // replacement would resolve it, which is why the hyphen fold lives on the - // master side of the index rather than in a key both sides share. - let spaced = ledgers(&["BRIDGE PROBE LEDGER A", "Beta Supply"]); - let binding = bind_one_name(&spaced, "BRIDGE-PROBE-LEDGER-A"); - assert_eq!(binding.bound_name(), None); - assert_eq!(candidate_names(&binding), ["BRIDGE PROBE LEDGER A"]); - - // `X - Y` is a common ledger convention, and reaching it from `X Y` needs - // the measured hyphen step *and* a whitespace run collapsed — which is not - // measured. So it suggests rather than resolves. This is the largest single - // cost of holding the fold to the evidence, and it is recorded here so that - // widening it again is a deliberate act with a test to change. - let spaced_hyphen = ledgers(&["Bank - HDFC Current", "Beta Supply"]); - let binding = bind_one_name(&spaced_hyphen, "Bank HDFC Current"); - assert_eq!(binding.bound_name(), None); - assert_eq!(candidate_names(&binding), ["Bank - HDFC Current"]); + // An en dash and an underscore were **sent and rejected**. They are not + // separators to Tally, however much they look like them, so they may only + // suggest — this is the half of §9.4d that a "normalises separators" + // reading would get wrong in the dangerous direction. + for (master, source) in [ + ("Alpha \u{2013} Traders", "Alpha Traders"), + ("Alpha_Traders", "Alpha Traders"), + ] { + let catalog = ledgers(&[master, "Beta Supply"]); + let binding = bind_one_name(&catalog, source); + assert_eq!( + binding.bound_name(), + None, + "{source} resolved onto {master} on an equivalence Tally rejects" + ); + assert_eq!(candidate_names(&binding), [master]); + } } #[test] @@ -525,19 +545,15 @@ fn masters_that_collapse_under_the_fold_are_refused_never_chosen() { // Refuse-ambiguous, never-pick: with no exact spelling to prefer, the // collapse is reported with both masters offered, not resolved to one. - let binding = bind_one_name(&catalog, "alpha beta"); - assert_eq!(reason(&binding), UnboundReason::NameAmbiguous); - assert_eq!(binding.bound_name(), None); - assert_eq!(candidate_names(&binding), ["Alpha Beta", "Alpha-Beta"]); - - // A whitespace run is not a verified transformation, so this one never - // reaches the narrow index at all. It is still refused, and still shows - // both — a near-miss rather than an ambiguity, which is the honest label: - // these two are not proven to collapse, they are merely both plausible. - let binding = bind_one_name(&catalog, "ALPHA BETA"); - assert_eq!(reason(&binding), UnboundReason::NearMiss); - assert_eq!(binding.bound_name(), None); - assert_eq!(candidate_names(&binding), ["Alpha Beta", "Alpha-Beta"]); + // Both spellings answer to one key now that §9.4d has measured the hyphen + // in both directions, so a third spelling reaching both is an ambiguity — + // and Tally agrees, because it would match that name to either. + for spelling in ["alpha beta", "ALPHA BETA", "alpha/beta"] { + let binding = bind_one_name(&catalog, spelling); + assert_eq!(reason(&binding), UnboundReason::NameAmbiguous, "{spelling}"); + assert_eq!(binding.bound_name(), None); + assert_eq!(candidate_names(&binding), ["Alpha Beta", "Alpha-Beta"]); + } } #[test] @@ -1721,15 +1737,13 @@ fn fabricated_document() -> Vec<(&'static str, Option<&'static str>, Expected)> // Named exactly as the book spells it. ("Cash", None, Expected::Bound("Cash")), ("CGST OUTPUT 9%", None, Expected::Bound("CGST OUTPUT 9%")), - // Case noise alone is measured, so it still resolves. + // Case noise alone resolves, and so does spacing noise: §9.4d measured + // leading whitespace and a collapsed run matching on licensed 7.1. ("cgst output 9%", None, Expected::Bound("CGST OUTPUT 9%")), - // Spacing noise from the source system is not. Leading whitespace and - // a collapsed run are both on §9.4b's unverified list, so this one is - // offered rather than answered — with `CGST OUTPUT 9%` first. ( " cgst output 9% ", None, - Expected::Unbound(UnboundReason::NearMiss), + Expected::Bound("CGST OUTPUT 9%"), ), ( "beta placeholder trading co", @@ -1824,8 +1838,8 @@ fn a_document_against_a_realistic_book_binds_only_where_a_human_would() { // The shape of the answer, pinned so a loosened threshold moves a number. let totals = report.totals(); assert_eq!(totals.requested, 13); - assert_eq!(totals.bound, 7); - assert_eq!(totals.ambiguous, 4); + assert_eq!(totals.bound, 8); + assert_eq!(totals.ambiguous, 3); assert_eq!(totals.unmatched, 2); assert_eq!(totals.requested, totals.bound + totals.unbound); } @@ -1915,7 +1929,6 @@ fn no_mutation_of_a_master_name_ever_binds_to_a_different_master() { let mut checked = 0_usize; let mut self_bound = 0_usize; let mut self_offered = 0_usize; - let mut downgraded = 0_usize; for name in &names { for mutation in source_mutations(name) { let key = comparison_key(&mutation); @@ -1937,9 +1950,6 @@ fn no_mutation_of_a_master_name_ever_binds_to_a_different_master() { !wide_would_bind || offered, "narrowing the fold hid {name:?} from its own mutation {mutation:?}" ); - if wide_would_bind { - downgraded += 1; - } } Some(bound_to) => { assert_eq!( @@ -1955,19 +1965,22 @@ fn no_mutation_of_a_master_name_ever_binds_to_a_different_master() { checked > 900, "the sweep must actually cover the book: {checked}" ); - // The narrowing this book measures. Most of these mutations are spacing - // and dash noise, and holding the resolving fold to the three - // transformations §9.4b actually verified stops most of them resolving. - // That is the intended trade and not the property under test — what has to - // hold is that a withdrawn answer left the right master **visible**, so the - // cost is one confirmation rather than a master a human never sees. - // The cost, stated rather than implied. Most of this book's mutations are - // spacing and dash noise, and holding the resolving fold to the evidence - // stops most of them resolving: they become a near-miss carrying the right - // master, which costs a confirmation and never a search. + // Most of this book's mutations are spacing and separator noise, and §9.4d + // measured Tally folding all of it on the SKU this writes to — so most of + // them resolve again. The per-case invariant above still holds and is the + // point: anything the wide fold reaches is bound or shown, never hidden. + // + // On this book the two folds now agree on every mutation, so the sweep does + // **not** exercise the gap between them. The cases that still separate them + // — an en dash, an underscore, an NFD spelling — are covered by + // `space_hyphen_and_slash_are_one_separator_in_both_directions` and + // `two_encodings_of_one_name_are_two_masters_to_tally_and_so_to_this` + // instead, and this comment exists so nobody reads a green sweep as + // evidence about them. assert!( - downgraded > 0, - "the sweep no longer exercises the narrowed fold at all" + self_bound * 2 > checked, + "most mutations should resolve once the fold matches the gateway: \ + {self_bound} bound of {checked}" ); assert!( self_bound + self_offered > checked * 2 / 3, From e840f05059e19407b70aaf0f7ffa1647193a43af Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 05:30:38 +0530 Subject: [PATCH 35/75] fix(source-draft): keep the reason when the listing or the refusal goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, and the first is this PR reintroducing its own defect one branch over. **A refusal reason did not survive the listing being dropped.** The budget-exhaustion branch added in the previous commit returned only the budget sentence, so an identifier/name conflict arriving with no room to list its candidates read as a plain "ran out of room" — exactly the hiding the neighbouring branch had just been fixed to stop. It now leads with the reason and then says the listing would not fit. **"Case and separators" is a class, and Tally does not fold it as one.** §9.4d measured which separators fold on the release this writes to: space, hyphen and slash do; an en dash and an underscore do not. Naming the class sent an operator hunting for variants that played no part in the refusal, and it is the generalisation §9.4d exists to stop. The copy now names what was measured. **The refusal summary outlived the refusal.** After the operator chose a target it kept rendering "nothing is chosen", directly beside the line telling them the target was re-read and bound. The reason still matters after a choice — an identifier and a name pointing at different ledgers is grounds to check it — so it survives in the past tense and the guidance that no longer applies goes. That split the "nothing is chosen" clause out of the reason leads, which is where it belonged anyway. Three mutation controls, one per fix. Co-Authored-By: Claude Opus 5 --- scripts/source-draft-screen.test.tsx | 82 +++++++++++++++++++++++++++- src/SourceDraftScreen.tsx | 37 ++++++++++--- 2 files changed, 110 insertions(+), 9 deletions(-) diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index be87dd8a..0be94966 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -389,7 +389,7 @@ test("names the refusal when the name and the identifier point at different ledg test("distinguishes the two other refusals that are not weak matches", async () => { for (const [reason, phrase] of [ ["master_binding_identifier_conflict", "do not agree on one existing ledger"], - ["master_binding_name_ambiguous", "once case and separators are set aside"], + ["master_binding_name_ambiguous", "spaces against hyphens or slashes are set aside"], ] as const) { mocks.invoke.mockReset(); mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce({ @@ -417,6 +417,84 @@ test("distinguishes the two other refusals that are not weak matches", async () } }); +test("a refusal reason survives the candidate listing being dropped", async () => { + // The budget-exhaustion branch returned only the budget sentence, which + // re-hid the strong disagreement the neighbouring branch had just been fixed + // to show. Same defect, one branch over: a conflict arriving with no room to + // list its candidates is still a conflict. + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce({ + ...catalog, + targets: ["Alpha placeholder", "Beta placeholder"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: null, + bound_basis: null, + unbound_reason: "master_binding_identifier_name_conflict", + candidates: [], + candidate_count: 6, + candidates_truncated: true, + }], + }); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + expect(host.textContent).toContain("matches one existing ledger exactly, while an identifier inside it matches a different one"); + expect(host.textContent).toContain("ran out of room to list them"); + root.unmount(); +}); + +test("choosing a target stops the screen saying nothing was chosen, without hiding why", async () => { + // The refusal summary kept rendering beneath a selected target, saying + // "nothing is chosen" directly beside the line telling the operator their + // target was re-read and bound. The reason still matters after the choice — + // an identifier and a name pointing at different ledgers is grounds to check + // it — so it survives in the past tense rather than being hidden. + const conflicted = { + ...catalog, + targets: ["Alpha placeholder", "Beta placeholder", "Gamma placeholder"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: null, + bound_basis: null, + unbound_reason: "master_binding_identifier_name_conflict", + candidates: ["Alpha placeholder", "Gamma placeholder"], + candidate_count: 2, + candidates_truncated: false, + }], + }; + // Selecting a target goes through the apply path, which re-reads: the draft + // it returns is what puts the ledger on the entry. + const chosen = { + ...draft, + revision: 2, + rows: draft.rows.map((item, index) => index === 0 ? { + ...item, + proposal: { ...item.proposal, entries: [{ ...item.proposal.entries[0], ledger: "Alpha placeholder" }] }, + } : item), + current_catalog_bindings: [{ row_position: 1, entry_position: 1 }], + }; + mocks.invoke + .mockResolvedValueOnce(draft) + .mockResolvedValueOnce(conflicted) + .mockResolvedValueOnce(chosen); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + expect(host.textContent).toContain("Nothing is chosen;"); + + await act(async () => setValue(host.querySelector("#source-draft-1-entry-0-ledger")!, "Alpha placeholder")); + expect(host.textContent).not.toContain("Nothing is chosen;"); + expect(host.textContent).toContain("Automatic binding did not resolve this line."); + expect(host.textContent).toContain("they disagree"); + root.unmount(); +}); + test("an empty list because the report ran out of room is not a family the name cannot separate", async () => { // Both arrive with an empty list and a nonzero count, and they call for // opposite actions. A withheld family is fixed by a fuller source name; @@ -476,7 +554,7 @@ test("lists candidates first for a near miss and states that nothing was chosen" expect(groups).toEqual(["Possible for this source line", "All 3 existing ledgers"]); const possible = Array.from(target.querySelectorAll("optgroup")[0].querySelectorAll("option")).map((option) => option.value); expect(possible).toEqual(["Alpha placeholder", "Gamma placeholder"]); - expect(host.textContent).toContain("No single ledger matched this source line, so nothing is chosen. 2 possible ledgers are listed first; the full list of 3 follows."); + expect(host.textContent).toContain("No single ledger matched this source line. Nothing is chosen; 2 possible ledgers are listed first, and the full list of 3 follows."); root.unmount(); }); diff --git a/src/SourceDraftScreen.tsx b/src/SourceDraftScreen.tsx index 904c6e11..45b10ef1 100644 --- a/src/SourceDraftScreen.tsx +++ b/src/SourceDraftScreen.tsx @@ -105,8 +105,21 @@ function narrowedTargets(binding: SourceDraftCatalogBinding | null) { /// name points at one ledger and the number inside it points at another — look /// like an ordinary weak match. That is the one case where the operator has /// real information to act on, and it was the case being hidden. -function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: number) { +function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: number, selected: boolean) { if (!binding) return null; + if (selected) { + // The operator has chosen. Saying "nothing is chosen" beside their choice + // is simply false, and it contradicted the adjacent line telling them the + // target was re-read and bound. + // + // The *reason* still matters though, and is not hidden: an identifier and a + // name pointing at different ledgers is grounds to check a choice, not + // something that stops being true once one is made. So the refusal survives + // in the past tense, without the guidance that no longer applies. + return binding.bound_target + ? null + : `Automatic binding did not resolve this line. ${catalogRefusalLead(binding.unbound_reason)}`; + } if (binding.bound_target) { // `identifier` covers both shapes the binder extracts — a numeric run and // an alphanumeric code such as a registration or part number — and the DTO @@ -135,11 +148,14 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: if (binding.unbound_reason === "master_binding_no_discriminating_candidate") { return `This source line matches ${binding.candidate_count} existing ledgers and tells them apart from none of them, so none is listed. Use a fuller source name, or choose from the full list of ${total}.`; } - return `This source line matches ${binding.candidate_count} existing ledgers, but this report ran out of room to list them. Choose from the full list of ${total}.`; + // Why it refused survives the listing being dropped. Returning only the + // budget sentence here re-hid the strong disagreement that the branch below + // had just been fixed to show — the same defect, one branch over. + return `${catalogRefusalLead(binding.unbound_reason)} ${binding.candidate_count} existing ledgers are involved, but this report ran out of room to list them. Choose from the full list of ${total}.`; } const listed = binding.candidates_truncated ? `${shown} of ${binding.candidate_count}` : `${shown}`; const lead = catalogRefusalLead(binding.unbound_reason); - return `${lead} ${listed} possible ${shown === 1 ? "ledger is" : "ledgers are"} listed first; the full list of ${total} follows.`; + return `${lead} Nothing is chosen; ${listed} possible ${shown === 1 ? "ledger is" : "ledgers are"} listed first, and the full list of ${total} follows.`; } /// Why binding refused, where the reason changes what the operator should look @@ -148,7 +164,7 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: function catalogRefusalLead(reason: string | null) { switch (reason) { case "master_binding_identifier_name_conflict": - return "This source name matches one existing ledger exactly, while an identifier inside it matches a different one. They disagree, so nothing is chosen."; + return "This source name matches one existing ledger exactly, while an identifier inside it matches a different one, and they disagree."; case "master_binding_identifier_conflict": // Two different shapes reach this reason: one identifier carried by // several ledgers, and several identifiers each reaching a different @@ -156,9 +172,14 @@ function catalogRefusalLead(reason: string | null) { // that does not exist. return "The identifiers in this source line do not agree on one existing ledger — either one of them appears in several, or they point at different ones."; case "master_binding_name_ambiguous": - return "More than one existing ledger carries this name once case and separators are set aside, and nothing measured says which one Tally would pick."; + // Not "separators": `TALLY_PROTOCOL_REFERENCE.md` §9.4d measured which + // ones fold on the release this writes to — space, hyphen and slash do, + // an en dash and an underscore do not. Naming the class would send an + // operator hunting for en-dash and underscore variants that played no + // part in the refusal, and it is the generalisation §9.4d exists to stop. + return "More than one existing ledger carries this name once upper and lower case, surrounding and repeated spaces, and spaces against hyphens or slashes are set aside, and nothing measured says which one Tally would pick."; default: - return "No single ledger matched this source line, so nothing is chosen."; + return "No single ledger matched this source line."; } } @@ -590,7 +611,9 @@ function SourceDraftEditor({ row, disabled, catalog, catalogSelections, catalogI const entryId = (name: string) => fieldId(`entry-${index}-${name}`); const binding = catalogBindingFor(catalog, row.position, index + 1); const narrowed = narrowedTargets(binding); - const bindingSummary = catalog ? catalogBindingSummary(binding, catalog.targets.length) : null; + const bindingSummary = catalog + ? catalogBindingSummary(binding, catalog.targets.length, Boolean(entry.ledger)) + : null; return

Source line {index + 1}{sourceEntryLabel(row.entries[index] ?? { position: index, source_ledger: "", source_amount: "", source_polarity: "" })}

From 60c5cb623654db6cc1a71cb9bf8917fea47ab982 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 12:00:00 +0530 Subject: [PATCH 36/75] fix(master-binding): four holes the previous round's own fixes opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six review findings on 35adca8b. Two of the three P1s are in code this PR added last round, which is the useful part of the record: a cost optimisation is a change to what the code *knows*, not only to what it spends. **A large holder set stopped answering the question it was asked.** Skipping the expansion past `MAX_CANDIDATES_PER_ENTITY` also skipped the push to `per_identifier`, so `identifier_points_elsewhere` went blind and a byte-exact name bound while its hint pointed entirely elsewhere. Size-dependent: correct at three holders, silently wrong at twenty-six. Membership of the exact master is one test rather than a materialization, so the bound survives and the invariant stops depending on family size. Both sides of the boundary are asserted. **A date range evaded the date guard by being fused first.** `20250911-20250912` strips to sixteen digits, which is no length `is_plausible_date` recognizes, and `is_period` reads neither half as a year range. Two guards, each blind from its own side. The components are now checked before the separator is removed — a guard belongs on the value the rule is about, not on whatever the pipeline happens to be holding. **The memo's protection depended on source order.** Enough distinct cheap misses at the head of a draft filled the entry cap, and the expensive repeated key behind them was then never cached — the stall it exists to prevent, reachable by reordering the same rows. `bind` now counts key multiplicity in one borrowing pass before binding anything and caches only what a second row will ask for again, which removes the ordering rather than making it cheaper to recover from. **A stock item resolved on a fold measured for ledgers.** §9.4d reads "ledgers, on licensed 7.1" in a scope paragraph this PR wrote, and the remeasurement is what made the fold wide enough for that to matter. A folded stock-item name now suggests and does not resolve; byte equality is unaffected. ADR 0016 §1 no longer says the rules are identical for both classes, because only the identifier rules are. Two `REMOTEID` comments now cite TALLY_PROTOCOL_REFERENCE.md §9.3 rather than the implementation guide: the behaviour is a gateway observation and the reference is where those live. Four mutation controls, one per fix. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 10 +- .../bridge-tally-core/src/master_binding.rs | 92 ++++++++++--- .../src/master_binding_tests.rs | 127 ++++++++++++++++++ 3 files changed, 209 insertions(+), 20 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 5983a789..571aab9b 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -83,9 +83,13 @@ colliding pair surfaces in the unbound list where an operator can see it. Failing a whole read to report one collision would block all the work it was performed for. -`MasterClass` is `Ledger` or `StockItem`. Both classes failed in practice, the -rules are identical for both, and the class is carried only so a report cannot -be applied to the wrong catalog. +`MasterClass` is `Ledger` or `StockItem`. Both classes failed in practice and +the identifier rules are identical for both, but **the name fold is not**: +§9.4d measured ledgers, and whether a stock item matches by the same rule was +never sent. So a folded stock-item name may *suggest* and may not resolve — +byte equality is unaffected, since it needs no fold. The class is carried both +so a report cannot be applied to the wrong catalog and because the evidence +behind the two differs. ### 2. The identifier is the key; the name is a hint diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index bdcaba1c..884e6629 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -340,7 +340,8 @@ pub struct Unresolved { /// narration.** A client-supplied `REMOTEID` is not it: Tally overwrites /// the attribute with its own value, so a key written there cannot be /// observed afterwards and cannot identify what to reallocate - /// (`docs/tally/IMPLEMENTATION_GUIDE.md` §3.3a, fourth property, verified). + /// (`docs/tally/TALLY_PROTOCOL_REFERENCE.md` §9.3, which records that the + /// attribute does not echo the client key on readback). /// A parked amount whose identity went into a write-only field is /// unreallocatable, and nothing about the write would say so. pub unresolved_identity: Vec, @@ -542,7 +543,7 @@ impl BindingReport { /// either way, so the obvious correction produces exactly the double-posting a /// parked entry exists to avoid, and says it worked. /// -/// Re-import under the same client `REMOTEID` (§3.3a) is a real correction +/// Re-import under the same client `REMOTEID` (§9.3) is a real correction /// path, but reaches only vouchers Bridge itself wrote; a hand-keyed voucher /// has no client key. This is why the retained identity travels in the /// narration: the Journal that reallocates it is written by a human or a later @@ -857,7 +858,31 @@ pub fn bind( return Err(MasterBindingError::TooManySourceEntities); } let mut budget = MAX_REPORT_CANDIDATE_BYTES; - let mut memo = CandidateMemo::new(); + // Which source keys actually repeat, decided before any of them is bound. + // + // A first-come cap made the memo's protection depend on **source order**: + // 1,024 distinct cheap misses at the head of a draft filled it, and the + // repeated expensive key behind them was then never cached — the stall the + // memo exists to prevent, reachable by reordering the same rows. Counting + // first removes the ordering entirely, and caches only what a second row + // will ask for again. + // + // This borrows the keys rather than cloning them, so it costs no more than + // the entity list it is counting. + let mut repeats: BTreeMap<&str, usize> = BTreeMap::new(); + for entity in entities { + *repeats.entry(entity.key.as_str()).or_insert(0) += 1; + } + let repeated = repeats + .into_iter() + .filter(|(_, count)| *count > 1) + .map(|(key, _)| key) + .collect::>(); + + let mut memo = SearchMemo { + seen: CandidateMemo::new(), + repeated, + }; Ok(BindingReport { class: catalog.class, catalog: catalog.fingerprint, @@ -883,13 +908,21 @@ pub fn bind( /// repeated-name case, which is the one that stalls, needs very few entries. type CandidateMemo = BTreeMap<(String, BTreeSet), (Vec<(usize, CandidateRule)>, usize)>; +/// One run's search scratch: what has already been computed, and which source +/// keys a second row will ask for again. Carried together because they are one +/// decision — whether this result is worth keeping — split across two values. +struct SearchMemo<'a> { + seen: CandidateMemo, + repeated: BTreeSet<&'a str>, +} + const MAX_CANDIDATE_MEMO_ENTRIES: usize = 1_024; fn bind_one( catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize, - memo: &mut CandidateMemo, + memo: &mut SearchMemo<'_>, ) -> EntityBinding { let exact = catalog.by_name.get(&entity.name).copied(); @@ -903,6 +936,7 @@ fn bind_one( let mut per_identifier: Vec> = Vec::new(); let mut identifier_matches = BTreeSet::new(); let mut identifier_conflict = false; + let mut large_holder_points_elsewhere = false; for identifier in &entity.identifiers { if let Some(holders) = catalog.by_identifier.get(identifier) { // An identifier held by more masters than a candidate list may show @@ -914,6 +948,16 @@ fn bind_one( // before the candidate memo is even consulted. if holders.len() > MAX_CANDIDATES_PER_ENTITY { identifier_conflict = true; + // Skipping the expansion must not skip the *question* the + // expansion was asked. `identifier_points_elsewhere` needs one + // fact from this set — whether it contains the byte-exact + // master — and that is a membership test, not a + // materialization. Dropping it made the invariant + // size-dependent: a hint pointing entirely elsewhere let the + // exact name bind, but only once the family grew past the cap. + if exact.is_some_and(|index| !holders.contains(&index)) { + large_holder_points_elsewhere = true; + } continue; } #[cfg(test)] @@ -950,11 +994,12 @@ fn bind_one( // union contains the exact master answers the shared case correctly and the // mixed case wrongly: `ACME 11111111` with a hint reaching `BETA 22222222` // has the exact master in the union while one identifier plainly disagrees. - let identifier_points_elsewhere = exact.is_some_and(|index| { - per_identifier - .iter() - .any(|reached| !reached.contains(&index)) - }); + let identifier_points_elsewhere = large_holder_points_elsewhere + || exact.is_some_and(|index| { + per_identifier + .iter() + .any(|reached| !reached.contains(&index)) + }); let status = if identifier_points_elsewhere { unresolved_status( catalog, @@ -1000,7 +1045,11 @@ fn bind_one( .get(&entity.binding_key) .map(Vec::as_slice) { - Some([index]) => BindingStatus::Bound { + // §9.4d measured **ledgers**. Whether stock items match by the + // same rule is not merely unmeasured, it was never sent — so a + // folded stock-item name may suggest and may not resolve. Byte + // equality is unaffected: it needs no fold and is checked above. + Some([index]) if catalog.class == MasterClass::Ledger => BindingStatus::Bound { catalog_name: catalog.entries[*index].name.clone(), basis: BindingBasis::NormalizedName, }, @@ -1042,7 +1091,7 @@ fn unresolved_status( exact: Option, identifier_matches: &BTreeSet, budget: &mut usize, - memo: &mut CandidateMemo, + memo: &mut SearchMemo<'_>, ) -> BindingStatus { let (mut candidates, masters_found) = remembered_candidates(catalog, entity, identifier_matches, memo); @@ -1124,10 +1173,10 @@ fn remembered_candidates( catalog: &MasterCatalog, entity: &SourceEntity, identifier_matches: &BTreeSet, - memo: &mut CandidateMemo, + memo: &mut SearchMemo<'_>, ) -> (Vec<(usize, CandidateRule)>, usize) { let key = (entity.key.clone(), identifier_matches.clone()); - if let Some(remembered) = memo.get(&key) { + if let Some(remembered) = memo.seen.get(&key) { return remembered.clone(); } let computed = collect_candidates(catalog, entity, identifier_matches); @@ -1136,10 +1185,11 @@ fn remembered_candidates( // recomputing it costs, multiplied by the cap — trading a stall for the // memory the aggregate bounds elsewhere exist to prevent. A large result is // cheap to recompute relative to what holding it costs, so it is not held. - let worth_holding = - key.1.len() <= MAX_CANDIDATES_PER_ENTITY && computed.0.len() <= MAX_CANDIDATES_PER_ENTITY; - if worth_holding && memo.len() < MAX_CANDIDATE_MEMO_ENTRIES { - memo.insert(key, computed.clone()); + let worth_holding = memo.repeated.contains(entity.key.as_str()) + && key.1.len() <= MAX_CANDIDATES_PER_ENTITY + && computed.0.len() <= MAX_CANDIDATES_PER_ENTITY; + if worth_holding && memo.seen.len() < MAX_CANDIDATE_MEMO_ENTRIES { + memo.seen.insert(key, computed.clone()); } computed } @@ -1528,8 +1578,16 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro !(character.is_ascii_digit() || character == '-' || character == '/') }) { let digits = run.chars().filter(char::is_ascii_digit).collect::(); + // Each side of a punctuated run is tested before the separator is + // removed. `20250911-20250912` fuses to sixteen digits, which is no + // length `is_plausible_date` recognizes, and `is_period` reads + // neither half as a year range — so a date *range* walked through a + // guard that a single date does not. Checking the components is + // where the check belonged: the fusing is what hid them. + let component_is_a_date = run.split(DASH_VARIANTS).any(is_plausible_date); if digits.len() >= MIN_NUMERIC_IDENTIFIER_DIGITS && digits.len() <= MAX_IDENTIFIER_CHARS + && !component_is_a_date && !is_plausible_date(&digits) && !is_period(run) { diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 4ed2715f..49a1c9b6 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -1049,6 +1049,133 @@ fn an_identifier_held_by_a_whole_family_is_a_conflict_without_expanding_it() { ); } +#[test] +fn a_hint_pointing_elsewhere_outranks_an_exact_name_at_any_family_size() { + // The large-holder skip added for cost made this invariant size-dependent: + // below the cap the holder set was built and `identifier_points_elsewhere` + // saw that it did not contain the exact master; above the cap the set was + // skipped, the signal went with it, and the exact name bound while the + // hint pointed entirely elsewhere. Skipping the expansion must not skip + // the question the expansion was asked. + let mut names = vec!["Alpha Traders".to_string()]; + names.extend( + (0..MAX_CANDIDATES_PER_ENTITY + 5) + .map(|index| format!("Other Party {index:03} (5550008888)")), + ); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let source = + SourceEntity::with_identifier_hints(0, "Alpha Traders", ["5550008888"]).expect("valid"); + let report = bound(&catalog, &[source]); + let binding = &report.entities()[0]; + assert_eq!( + binding.bound_name(), + None, + "a byte-exact name bound while its hint pointed at a different family" + ); + assert_eq!(reason(binding), UnboundReason::IdentifierNameConflict); + + // Below the cap the same shape already behaved; both sides of the boundary + // are asserted so the fix cannot regress on one of them alone. + let mut small = vec!["Alpha Traders".to_string()]; + small.extend((0..3).map(|index| format!("Other Party {index:03} (5550008888)"))); + let small = MasterCatalog::new(MasterClass::Ledger, &small).expect("valid"); + let source = + SourceEntity::with_identifier_hints(0, "Alpha Traders", ["5550008888"]).expect("valid"); + let report = bound(&small, &[source]); + assert_eq!( + reason(&report.entities()[0]), + UnboundReason::IdentifierNameConflict + ); +} + +#[test] +fn a_date_range_is_dates_even_after_its_separator_is_removed() { + // `20250911-20250912` fuses to sixteen digits, which is no length + // `is_plausible_date` recognizes, and `is_period` reads neither half as a + // year range. So a date *range* walked through a guard a single date does + // not — the fusing is what hid the components, so they are checked first. + for range in [ + "20250911-20250912", + "20250911/20250912", + "01012026-02012026", + "20250911-20250912-20250913", + ] { + assert!( + entity(&format!("Purchases {range}")) + .identifiers() + .is_empty(), + "{range} was treated as an identifier" + ); + } + let catalog = ledgers(&["Sales 20250911-20250912", "Beta Supply"]); + assert_eq!( + bind_one_name(&catalog, "Purchases 20250911-20250912").bound_name(), + None, + "a shared date range must not bind two unrelated ledgers" + ); + // A punctuated account number is untouched: no component reads as a date. + assert_eq!(entity("Party 5550001-002").identifiers().len(), 1); +} + +#[test] +fn a_stock_item_may_suggest_on_a_fold_but_not_resolve_on_one() { + // §9.4d measured **ledgers**. Whether stock items match by the same rule + // was never sent, so the same folded pair that resolves for a ledger may + // only be offered for a stock item. + let folded = ["Sales-Item", "Beta Supply"]; + let ledger = MasterCatalog::new(MasterClass::Ledger, folded).expect("valid"); + assert_eq!( + bind_one_name(&ledger, "sales item").bound_name(), + Some("Sales-Item") + ); + + let items = MasterCatalog::new(MasterClass::StockItem, folded).expect("valid"); + let source = SourceEntity::new(0, "sales item").expect("valid"); + let report = bind(&items, &[source]).expect("valid"); + let binding = &report.entities()[0]; + assert_eq!( + binding.bound_name(), + None, + "a stock item resolved on an unmeasured fold" + ); + assert_eq!(candidate_names(binding), ["Sales-Item"]); + + // Byte equality needs no fold and is unaffected by the class. + let exact = SourceEntity::new(0, "Sales-Item").expect("valid"); + let report = bind(&items, &[exact]).expect("valid"); + assert_eq!(report.entities()[0].bound_name(), Some("Sales-Item")); +} + +#[test] +fn a_repeated_key_is_remembered_however_many_distinct_ones_precede_it() { + // The entry cap made the memo's protection depend on **source order**: + // enough distinct cheap misses at the head of a draft filled it, and the + // repeated expensive key behind them was then never cached — the stall the + // memo exists to prevent, reachable by reordering the same rows. + let names = (0..60) + .map(|index| format!("Acme Branch {index:05}")) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + + let mut entities = (0..MAX_CANDIDATE_MEMO_ENTRIES) + .map(|index| SourceEntity::new(index, &format!("Distinct Miss {index:05}")).expect("valid")) + .collect::>(); + entities.extend((0..4).map(|offset| { + SourceEntity::new(MAX_CANDIDATE_MEMO_ENTRIES + offset, "Acme Branch").expect("valid") + })); + + super::CANDIDATE_SEARCHES.with(|count| count.set(0)); + let report = bound(&catalog, &entities); + let searches = super::CANDIDATE_SEARCHES.with(std::cell::Cell::get); + assert_eq!(report.totals().requested, MAX_CANDIDATE_MEMO_ENTRIES + 4); + // One search for the repeated key, not four. The distinct misses each cost + // one of their own, so the total is bounded by the distinct count plus one. + assert!( + searches <= MAX_CANDIDATE_MEMO_ENTRIES + 1, + "the repeated key was searched more than once: {searches} searches" + ); +} + #[test] fn a_report_bounds_its_own_candidate_allocation() { // A per-entity cap does not bound a report: the clones exist the moment it From 6858bd909a5519403990afacbd3116d6e5e39af9 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 12:43:13 +0530 Subject: [PATCH 37/75] =?UTF-8?q?fix(master-binding):=20close=20four=20hol?= =?UTF-8?q?es,=20and=20label=20what=20=C2=A79.4d=20measured?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round's fixes each opened a smaller hole behind them. - Identifier canonicalisation dropped `-` and `/` but nothing else, so two codes differing only in unmeasured punctuation collapsed into one. It now removes only the two separators the gateway was measured to fold. - A family larger than the cap was skipped without recording its size, so a withheld family reported no count at all. It now reports how many share the identifier even when the holders are not expanded. - A hint pointing away from an exact name was only honoured below the cap. An exact name no longer outranks a hint at any family size. - A stock item could resolve on a fold. It may now suggest on one and nothing more, and the test asserts the reason, not only the outcome. Three of the round's earlier tests asserted the outcome where the fix was about the work done to reach it, and passed with the fix reverted. Two test-only counters (`HOLDER_EXPANSIONS`, `CANDIDATE_SEARCHES`) now let them assert the work. §9.4d recorded `AND` for `&` as rejected, but the probe that produced the row sent a name with `AND CO` appended, against a master carrying no `&`. That measures an added suffix. The substitution was re-measured against `Profit & Loss A/c`, a reserved ledger present in every company, and the section now carries four accurately-labelled rows and says which of them it measured. The verdict was unchanged; the label was wrong. Co-Authored-By: Claude Opus 5 --- docs/tally/TALLY_PROTOCOL_REFERENCE.md | 20 +++ .../bridge-tally-core/src/master_binding.rs | 117 +++++++++++++++--- .../src/master_binding_tests.rs | 87 ++++++++++++- 3 files changed, 201 insertions(+), 23 deletions(-) diff --git a/docs/tally/TALLY_PROTOCOL_REFERENCE.md b/docs/tally/TALLY_PROTOCOL_REFERENCE.md index e7be55b5..d6c1d356 100644 --- a/docs/tally/TALLY_PROTOCOL_REFERENCE.md +++ b/docs/tally/TALLY_PROTOCOL_REFERENCE.md @@ -1259,8 +1259,28 @@ eight created vouchers were then deleted by `REMOTEID` and the day read back emp | an **en dash** where the master has a space | **rejected** | *UNVERIFIED* | | an **underscore** where the master has a space | **rejected** | *UNVERIFIED* | | `AND` for `&` | **rejected** | rejected | +| a **missing** suffix word | **rejected** | rejected | +| an **added** suffix word | **rejected** | not sent | | **NFD** against an NFC master | **rejected** | not sent | +**One row here was mislabelled and is corrected.** The first run of this probe recorded `AND` for +`&` as rejected, but what it actually sent was a name with `AND CO` **appended** — against a master +carrying no `&` at all. That measures an added suffix, not a substitution, and the label was wrong +even though the verdict happened to be. It was re-run against `Profit & Loss A/c`, a reserved +ledger present in every company: + +| supplied against live `Profit & Loss A/c` | result | +| --- | --- | +| `Profit & Loss A/c` | **matched** — control | +| `profit & loss a/c` | **matched** — case folds on a name carrying `&` and `/` | +| `Profit AND Loss A/c` | **rejected** — the substitution, now measured here | +| `profit and loss a/c` | **rejected** | +| `Profit & Loss` | **rejected** — a missing suffix word | +| `Profit & Loss A/c AND CO` | **rejected** — an added suffix word, what the first run really sent | + +So §9.4b's abbreviation findings hold on licensed 7.1 as well, and this section now says which +of them it measured rather than which it meant to. + **Composition was measured separately, because twelve single-axis results do not license it.** Each row above is **one** transformation away from exact, so together they say each transformation works alone and nothing about applying several at once — which is exactly what any fold does. Two diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 884e6629..230c9673 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -186,6 +186,11 @@ pub struct Identifier { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum CandidateRule { + /// Byte-equal to the observed master name. Only reachable on a refusal: + /// where byte equality settles the question it binds instead, so this + /// appears exactly when something outranked it — an identifier pointing + /// elsewhere — and it is the other half of that disagreement. + ExactName, /// Shares an embedded identifier, but the identifier was not decisive. SharedIdentifier, /// Equal under the comparison key, but the key was not unique. @@ -201,11 +206,12 @@ pub enum CandidateRule { impl CandidateRule { fn rank(self) -> u8 { match self { - Self::SharedIdentifier => 0, - Self::NormalizedEqual => 1, - Self::CatalogPrefix => 2, - Self::SourcePrefix => 3, - Self::SharedToken => 4, + Self::ExactName => 0, + Self::SharedIdentifier => 1, + Self::NormalizedEqual => 2, + Self::CatalogPrefix => 3, + Self::SourcePrefix => 4, + Self::SharedToken => 5, } } } @@ -911,6 +917,20 @@ type CandidateMemo = BTreeMap<(String, BTreeSet), (Vec<(usize, CandidateR /// One run's search scratch: what has already been computed, and which source /// keys a second row will ask for again. Carried together because they are one /// decision — whether this result is worth keeping — split across two values. +/// What the identifier pass established for one entity, before any name was +/// compared. Carried together because every consumer needs all three and the +/// awkward one — how large a family was skipped — is meaningless without the +/// other two. +struct IdentifierEvidence<'a> { + /// The master whose name the source matches byte for byte, if any. + exact: Option, + /// Every master the entity's identifiers reached. + matches: &'a BTreeSet, + /// The largest family skipped rather than expanded, so a withheld listing + /// can still say how many masters share the identifier. + withheld_holders: usize, +} + struct SearchMemo<'a> { seen: CandidateMemo, repeated: BTreeSet<&'a str>, @@ -937,6 +957,11 @@ fn bind_one( let mut identifier_matches = BTreeSet::new(); let mut identifier_conflict = false; let mut large_holder_points_elsewhere = false; + // How many masters a skipped family actually held. The set is not built, + // but the *count* is the one thing a reader still needs: without it a + // withheld family reported `found() == 0` and `listing: "none"`, telling + // the operator nothing shares the identifier when hundreds do. + let mut withheld_holders = 0_usize; for identifier in &entity.identifiers { if let Some(holders) = catalog.by_identifier.get(identifier) { // An identifier held by more masters than a candidate list may show @@ -948,6 +973,7 @@ fn bind_one( // before the candidate memo is even consulted. if holders.len() > MAX_CANDIDATES_PER_ENTITY { identifier_conflict = true; + withheld_holders = withheld_holders.max(holders.len()); // Skipping the expansion must not skip the *question* the // expansion was asked. `identifier_points_elsewhere` needs one // fact from this set — whether it contains the byte-exact @@ -1005,8 +1031,11 @@ fn bind_one( catalog, entity, UnboundReason::IdentifierNameConflict, - exact, - &identifier_matches, + IdentifierEvidence { + exact, + matches: &identifier_matches, + withheld_holders, + }, budget, memo, ) @@ -1023,8 +1052,11 @@ fn bind_one( catalog, entity, UnboundReason::IdentifierConflict, - exact, - &identifier_matches, + IdentifierEvidence { + exact, + matches: &identifier_matches, + withheld_holders, + }, budget, memo, ) @@ -1053,12 +1085,31 @@ fn bind_one( catalog_name: catalog.entries[*index].name.clone(), basis: BindingBasis::NormalizedName, }, + // A single folded match that the class does not license is not an + // ambiguity — nothing shares its key. `NameAmbiguous` would tell a + // consumer that several masters collided when exactly one did not + // qualify, which is a different fact with a different remedy. + Some([_]) => unresolved_status( + catalog, + entity, + UnboundReason::NearMiss, + IdentifierEvidence { + exact, + matches: &identifier_matches, + withheld_holders, + }, + budget, + memo, + ), Some(_) => unresolved_status( catalog, entity, UnboundReason::NameAmbiguous, - exact, - &identifier_matches, + IdentifierEvidence { + exact, + matches: &identifier_matches, + withheld_holders, + }, budget, memo, ), @@ -1072,7 +1123,14 @@ fn bind_one( } else { UnboundReason::NoCandidate }; - unresolved_from(catalog, entity, reason, candidates, masters_found, budget) + unresolved_from( + catalog, + entity, + reason, + candidates, + masters_found.max(withheld_holders), + budget, + ) } } }; @@ -1088,19 +1146,34 @@ fn unresolved_status( catalog: &MasterCatalog, entity: &SourceEntity, reason: UnboundReason, - exact: Option, - identifier_matches: &BTreeSet, + evidence: IdentifierEvidence<'_>, budget: &mut usize, memo: &mut SearchMemo<'_>, ) -> BindingStatus { + let IdentifierEvidence { + exact, + matches: identifier_matches, + withheld_holders, + } = evidence; let (mut candidates, masters_found) = remembered_candidates(catalog, entity, identifier_matches, memo); if let Some(index) = exact { - if !candidates.iter().any(|(candidate, _)| *candidate == index) { - candidates.push((index, CandidateRule::NormalizedEqual)); - } + // Byte equality, labelled as itself. Adding it as `NormalizedEqual` + // hid the strongest name evidence there is and sorted it behind the + // identifier that outranked it — on a conflict produced *because* byte + // equality was observed, which left the operator reading the two facts + // that disagreed without being told one of them was exact. + candidates.retain(|(candidate, _)| *candidate != index); + candidates.push((index, CandidateRule::ExactName)); } - unresolved_from(catalog, entity, reason, candidates, masters_found, budget) + unresolved_from( + catalog, + entity, + reason, + candidates, + masters_found.max(withheld_holders), + budget, + ) } fn unresolved_from( @@ -1523,9 +1596,15 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro } else if token.chars().any(char::is_alphanumeric) { previous_was_mask = false; } + // Only the separators §9.4d measured may be discarded. Filtering to + // alphanumerics dropped **every** ASCII punctuation mark, so + // `AB_123456` and `AB-123456` canonicalized alike and one identifier + // bound the other's master — while §9.4d had sent an underscore and + // watched Tally *reject* it. The evidence for this fold is one + // measurement about hyphens and slashes; everything else stays content. let canonical = token .chars() - .filter(|character| character.is_ascii_alphanumeric()) + .filter(|character| !matches!(character, '-' | '/')) .map(|character| character.to_ascii_uppercase()) .collect::(); let digits = canonical.chars().filter(char::is_ascii_digit).count(); diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 49a1c9b6..a3d81bf1 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -802,9 +802,16 @@ fn a_name_in_another_script_does_not_shed_its_letters_into_a_code() { bind_one_name(&bank, &format!("Purchases 12345678{digits}")).bound_name(), None ); - // The dash variants stay admitted: this module already folds them as - // separators, and a punctuated code must still agree with a plain one. + // Only the separators §9.4d measured are discarded from a code. An ASCII + // hyphen and slash are; a non-breaking hyphen is not, because §9.4d sent a + // non-ASCII dash and watched Tally reject it. So a code punctuated with one + // no longer agrees with the plain spelling — a refusal, which is the safe + // direction, and the token is still admitted as a candidate by name. assert_eq!( + entity("Item PH-01/AB-00").identifiers(), + entity("Item PH01AB00").identifiers() + ); + assert_ne!( entity("Item PH\u{2011}01AB00").identifiers(), entity("Item PH01AB00").identifiers() ); @@ -1139,6 +1146,11 @@ fn a_stock_item_may_suggest_on_a_fold_but_not_resolve_on_one() { "a stock item resolved on an unmeasured fold" ); assert_eq!(candidate_names(binding), ["Sales-Item"]); + // A lone unlicensed fold is a **near miss**, not an ambiguity. Nothing + // shares its key; one master simply did not qualify, and `NameAmbiguous` + // would tell a consumer several masters collided — a different fact with a + // different remedy. + assert_eq!(reason(binding), UnboundReason::NearMiss); // Byte equality needs no fold and is unaffected by the class. let exact = SourceEntity::new(0, "Sales-Item").expect("valid"); @@ -1176,6 +1188,66 @@ fn a_repeated_key_is_remembered_however_many_distinct_ones_precede_it() { ); } +#[test] +fn unmeasured_punctuation_keeps_two_codes_apart() { + // Canonicalization filtered to alphanumerics, so **every** ASCII + // punctuation mark was discarded and `AB_123456` canonicalized the same as + // `AB-123456` — while §9.4d had sent an underscore at a live master and + // watched Tally reject it. The fold's evidence is about hyphens and + // slashes; everything else is content. + let catalog = ledgers(&["Sales AB-123456", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Purchases AB_123456"); + assert_eq!( + binding.bound_name(), + None, + "an underscore was treated as a hyphen on evidence that says it is not" + ); + // The measured separators still agree, and the contrast is the point: with + // a hyphen the two codes are one identifier and the bind is the + // identifier-first rule working; with an underscore they are two + // identifiers and nothing binds. + let measured = ledgers(&["Sales PH-01-AB-00", "Beta Supply"]); + assert_eq!( + bind_one_name(&measured, "Purchases PH01AB00").bound_name(), + Some("Sales PH-01-AB-00"), + "a hyphen and no hyphen are one code, which §9.4d measured" + ); + assert_eq!( + entity("Item PH-01-AB-00").identifiers(), + entity("Item PH01AB00").identifiers() + ); + assert_ne!( + entity("Item AB_123456").identifiers(), + entity("Item AB-123456").identifiers() + ); +} + +#[test] +fn a_withheld_family_still_reports_how_many_share_the_identifier() { + // Skipping the expansion discarded the holder count with the set, so a + // withheld family reported `found() == 0` and an empty listing — telling + // the operator nothing shares the identifier when hundreds do. The count is + // the one thing a reader still needs from a set too large to show. + let names = (0..MAX_CANDIDATES_PER_ENTITY + 7) + .map(|index| format!("Shared Party {index:03} (5550007777)")) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let source = + SourceEntity::with_identifier_hints(0, "Zeta Holdings", ["5550007777"]).expect("valid"); + let report = bound(&catalog, &[source]); + let unresolved = report.entities()[0].unresolved().expect("unbound"); + assert_eq!(unresolved.reason, UnboundReason::IdentifierConflict); + assert_eq!( + unresolved.candidates.found(), + MAX_CANDIDATES_PER_ENTITY + 7, + "a withheld family reported no holders at all" + ); + assert!( + unresolved.candidates.is_incomplete(), + "a count without a listing must say the listing is incomplete" + ); +} + #[test] fn a_report_bounds_its_own_candidate_allocation() { // A per-entity cap does not bound a report: the clones exist the moment it @@ -1243,10 +1315,17 @@ fn conflicting_identifiers_outrank_a_byte_exact_name_but_a_shared_one_does_not() let binding = &report.entities()[0]; assert_eq!(reason(binding), UnboundReason::IdentifierNameConflict); // Every master the evidence reached is offered, so the operator sees the - // disagreement rather than one side of it. + // disagreement rather than one side of it — and the byte-exact name leads, + // labelled as itself. This refusal exists *because* byte equality was + // observed, so burying that under the identifier that outranked it left + // the operator reading two facts without being told one of them was exact. assert_eq!( candidate_names(binding), - ["BETA 11111111", "GAMMA 22222222", "ACME"] + ["ACME", "BETA 11111111", "GAMMA 22222222"] + ); + assert_eq!( + binding.unresolved().expect("unbound").candidates.listed()[0].rule, + CandidateRule::ExactName ); // The shared-identifier case must keep binding: one identifier reached the From 4fe09f410bdd41f9177a965aa760dcd8d1f951a1 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 12:49:11 +0530 Subject: [PATCH 38/75] =?UTF-8?q?docs(tally):=20say=20what=20=C2=A79.4d's?= =?UTF-8?q?=20probe=20counted,=20and=20cite=20the=20guide=20by=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method paragraph still described the first run alone — twelve variants, eight vouchers deleted — after the re-measurement added six more. It now counts both runs. `the_master_fold_stops_where_tally_stops` cited "§3.3b" without naming the document that carries it, which is `IMPLEMENTATION_GUIDE.md`, not this reference; the same three rows now also point at §9.4d, where two of them were re-measured on licensed 7.1. Compatibility surface resealed for the changed pin. Co-Authored-By: Claude Opus 5 --- docs/tally/TALLY_PROTOCOL_REFERENCE.md | 7 ++++--- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- .../crates/bridge-tally-core/src/master_binding_tests.rs | 6 ++++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/tally/TALLY_PROTOCOL_REFERENCE.md b/docs/tally/TALLY_PROTOCOL_REFERENCE.md index 08f1d567..56fed873 100644 --- a/docs/tally/TALLY_PROTOCOL_REFERENCE.md +++ b/docs/tally/TALLY_PROTOCOL_REFERENCE.md @@ -1274,9 +1274,10 @@ licence tier silver, `education_mode=false`**, ledgers, one lab company. **Method is §9.4b's own.** Import a voucher naming a folded spelling of a ledger that exists, and let Tally answer: a created voucher means the name resolved, a `LINEERROR` naming that ledger -means it did not. Twelve variants, one voucher each, then the **day book was read back** to record -which master each voucher actually posted against — the counters alone would not have said. All -eight created vouchers were then deleted by `REMOTEID` and the day read back empty. +means it did not. Twelve variants in the first run and six more in the second described below, one +voucher each, then the **day book was read back** to record which master each voucher actually +posted against — the counters alone would not have said. Every created voucher was then deleted by +`REMOTEID` and the day read back empty (eight from the first run, two from the second). | Supplied against a live master | Licensed 7.1 | §9.4b on Educational | | --- | --- | --- | diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 3fed0fa4..75d939db 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "9998a213bc5dce6205e6909959a88b5f6844febef386274719dd6d39e2a5bbf6", + "compatibility_surface_sha256": "b823532048f5ef04ea8f8affe65278c66cc73461d0adaff6c3e1b1a412ac1a58", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 7d55b9e4..46964fc8 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -35,7 +35,7 @@ }, { "path": "docs/tally/TALLY_PROTOCOL_REFERENCE.md", - "sha256": "d2172a6fa4253846509ba86c82749142bef7c180cc016e0261e1450d0d60d763" + "sha256": "724cf4ae4c34ae39858959eb3da8ca488b740db8c5234a651d0574228ae5bbcf" }, { "path": "docs/tally/compatibility/README.md", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "9998a213bc5dce6205e6909959a88b5f6844febef386274719dd6d39e2a5bbf6" + "manifest_sha256": "b823532048f5ef04ea8f8affe65278c66cc73461d0adaff6c3e1b1a412ac1a58" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index a3d81bf1..673fb4ef 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -608,8 +608,10 @@ fn masters_that_collapse_under_the_fold_are_refused_never_chosen() { #[test] fn the_master_fold_stops_where_tally_stops() { - // §3.3b also measured what Tally does NOT normalise: `AND` for `&`, a - // missing suffix word, and a singular for a plural were all rejected. + // `IMPLEMENTATION_GUIDE.md` §3.3b also measured what Tally does NOT + // normalise: `AND` for `&`, a missing suffix word, and a singular for a + // plural were all rejected. The first two were re-measured on licensed + // 7.1 in §9.4d against `Profit & Loss A/c` and rejected there too. // Folding further than the authority would bind names Tally refuses. let catalog = ledgers(&["ZZ Ram & Sons Pvt Ltd", "Beta Supply"]); for wrong in [ From c95edb6b1dd941e6940edf565d9896bc7cc4b554 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 12:57:15 +0530 Subject: [PATCH 39/75] fix(source-draft): one selection predicate, and real evidence on the capture fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the same review, all about a claim being wider than what produced it. **The summary took any saved ledger for a current-session choice.** The `` shows it as unchosen and the status line + // calls it unverified, so a summary keyed on `entry.ledger` alone + // said the operator had chosen while its neighbours said they had + // not — and hid the binding result they still needed. + const selectedKey = catalogSelectionKey(row.position, index + 1); + const selectedNow = catalogSelections[selectedKey] === entry.ledger; const bindingSummary = catalog - ? catalogBindingSummary(binding, catalog.targets.length, Boolean(entry.ledger)) + ? catalogBindingSummary(binding, catalog.targets.length, selectedNow) : null; return

Source line {index + 1}{sourceEntryLabel(row.entries[index] ?? { position: index, source_ledger: "", source_amount: "", source_polarity: "" })}

{catalog ? <> - event.target.value && onSelectExistingLedger(row.position, index + 1, event.target.value)} disabled={disabled}> {narrowed.length > 0 && {narrowed.map((target) => )} @@ -630,7 +639,7 @@ function SourceDraftEditor({ row, disabled, catalog, catalogSelections, catalogI {entry.ledger && } -

{catalogSelections[catalogSelectionKey(row.position, index + 1)] === entry.ledger ? "This current-session target was re-read and bound. It remains an unapproved proposal." : catalogInvalidatedSelections[catalogSelectionKey(row.position, index + 1)] === entry.ledger ? `Tally changed after this target was bound. Saved unverified target: ${entry.ledger}. Select it to check it against this current capture.` : entry.ledger ? `Saved unverified target: ${entry.ledger}. Select it to check it against this current capture.` : "Choose a current existing ledger to make an unapproved proposal."}

+

{selectedNow ? "This current-session target was re-read and bound. It remains an unapproved proposal." : catalogInvalidatedSelections[selectedKey] === entry.ledger ? `Tally changed after this target was bound. Saved unverified target: ${entry.ledger}. Select it to check it against this current capture.` : entry.ledger ? `Saved unverified target: ${entry.ledger}. Select it to check it against this current capture.` : "Choose a current existing ledger to make an unapproved proposal."}

{bindingSummary &&

{bindingSummary}

} : <> onUpdateEntry(index, (current) => ({ ...current, ledger: emptyToNull(event.target.value) }))} disabled={disabled} /> From ae73fd8e641cdd7856fee7d4d5a73a8c3eb9a31c Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:05:13 +0530 Subject: [PATCH 40/75] test(source-draft): drive the grouped control at a live catalogue's size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture is nine ledgers and the fabricated catalogues are three. Both are real shapes; neither is a real size, and size is the one dimension no capture can supply, because no lab company carries thousands of ledgers. Two thousand and one targets, one of them matched: the narrowed group still leads with the single match, and the full list is still 2,001 long. Slicing the full list to 200 fails this and nothing else in the file — every other test here works at three targets, where truncation is invisible. Co-Authored-By: Claude Opus 5 --- scripts/source-draft-screen.test.tsx | 41 +++++++++++++++++++++++++++ src-tauri/src/source_draft/catalog.rs | 9 ++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index d80973bb..8f057da2 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -259,6 +259,47 @@ test("clears saved status when a proposal changes after saving", async () => { root.unmount(); }); +test("groups and lists a catalogue of realistic size without losing the narrowing", async () => { + // The captured catalogue is nine ledgers, which is a real shape but not a + // real size; the fabricated ones here are three. A live company's ledger + // count runs into the thousands, and that is where narrowing earns its place + // — and where a defect in it would be invisible at three targets. Size is + // the one dimension of this control that a capture cannot supply, because + // no lab company has thousands of ledgers. + const bulk = Array.from({ length: 2_000 }, (_, index) => `Bulk placeholder ledger ${String(index).padStart(4, "0")}`); + const large = { + ...catalog, + targets: [...bulk, "Existing target"].sort(), + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: "Existing target", + bound_basis: "exact_name", + unbound_reason: null, + candidates: [], + candidate_count: 0, + candidates_truncated: false, + }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(large); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + + const target = host.querySelector("#source-draft-1-entry-0-ledger")!; + const groups = Array.from(target.querySelectorAll("optgroup")); + expect(groups.map((group) => group.label)).toEqual(["Matched to this source line", "All 2001 existing ledgers"]); + // The match leads, alone, out of two thousand and one. + expect(Array.from(groups[0].querySelectorAll("option")).map((option) => option.value)).toEqual(["Existing target"]); + // And the whole catalogue is still there: narrowing is a shortcut through + // the list, never a restriction on it, at any size. + expect(groups[1].querySelectorAll("option")).toHaveLength(2_001); + expect(target.value).toBe(""); + root.unmount(); +}); + test("keeps the binding result visible beside a saved target nobody has re-read", async () => { // `entry.ledger` alone is not a choice. A saved target from an earlier // session leaves `catalogSelections` empty, the control shows nothing diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index 26b31ed3..3f119b3d 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -772,18 +772,15 @@ mod tests { )) .expect("the capture's provenance sidecar parses"); assert_eq!( - committed["evidence"]["response_sha256"], - provenance["source_response_sha256"], + committed["evidence"]["response_sha256"], provenance["source_response_sha256"], "the fixture no longer carries the captured response digest" ); assert_eq!( - committed["evidence"]["request_sha256"], - provenance["source_request_sha256"], + committed["evidence"]["request_sha256"], provenance["source_request_sha256"], "the fixture no longer carries the captured request digest" ); assert_eq!( - committed["evidence"]["bytes"], - provenance["source_response_bytes"], + committed["evidence"]["bytes"], provenance["source_response_bytes"], "the fixture no longer carries the captured response size" ); assert_eq!( From 153905ab5c343fd3e07f8592dc0eeef5c4d1edb7 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:15:49 +0530 Subject: [PATCH 41/75] fix(master-binding): four more, three of them behind last round's own fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **A byte is not a unit of evidence.** The code threshold was `canonical.len()`, which is UTF-8 bytes. An admitted dash variant is three of them, so `AB–123` measured eight against a constant named `MIN_CODE_IDENTIFIER_CHARS` and became decisive on five alphanumerics — while its ASCII twin `AB-123` reduces to `AB123` and is refused. The same code identified or did not depending on which dash the document carried. It now counts letters and digits, which also closes the padding shape bytes could never see. The upper bound stays in bytes: it bounds what the index stores, and a byte cap admits no more characters than it says. **One family's size is not the size of their union.** A skipped family of thirty beside an identifier reaching a thirty-first reported thirty. The listed masters not in the skipped family are provably disjoint from it, so they are added — a bisection per master, no set built. It stays a lower bound, and the field now says so rather than describing the implementation. **The memo's repeat count read half its key.** Entries are keyed by source key *and* the masters the identifiers reached, but repetition was counted on the key alone, so 1,024 hint variants of one name filled a cache nothing asks for twice and the pair that did repeat behind them could not be inserted — the source-order defect again, through the back door. Counted on `(key, identifiers)` now, still borrowed. **Only one side of an untrusted pair was bounded.** The catalog constructor has an aggregate byte budget because count and length bounds do not bound their product; the source side had none, and 40,000 individually valid entities are two and a half gigabytes of names before this module clones one. 16 MiB, checked at `bind`, counting both retained folds beside the name. Each fix has a test whose control is the fix reverted, and each control fails its own test alone. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 16 +- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 122 +++++++++++++- .../src/master_binding_tests.rs | 155 ++++++++++++++++++ 5 files changed, 283 insertions(+), 16 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 571aab9b..38dcd522 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -66,11 +66,17 @@ The constructor refuses, rather than degrades, on: - **an identifier hint that yields no identifier** — `IdentifierHintUnusable`. A hint that silently does nothing is a trap (P7); - bounds violations on entry count, entity count, name length, **total catalog - name bytes**, and **hint count**. The byte bound is not redundant with the - other two: 20,000 names of 16,384 characters satisfies both and is 327 MB - before the constructor builds its keys, tokens and four indexes over them. It - is accumulated as the iterator is consumed, so a lazy catalog fails before the - next name is retained rather than after all of them are. The last is checked as the hints arrive rather than on the finished + name bytes**, **total source name bytes**, and **hint count**. The byte bounds + are not redundant with the count and length ones: 20,000 names of 16,384 + characters satisfies both and is 327 MB before the constructor builds its + keys, tokens and four indexes over them. The catalog's is accumulated as the + iterator is consumed, so a lazy catalog fails before the next name is retained + rather than after all of them are. The source side had no such bound at all + until a review asked why only one side of an equally untrusted pair carried + one — 40,000 entities of 16,384 characters is two and a half gigabytes of + names, each individually valid — and it is now checked at `bind`, the boundary + where the collection first becomes this module's problem, as + `SourceNamesTooLarge`. The last is checked as the hints arrive rather than on the finished set: hints deduplicate, so a million repeated ones fold to a single identifier and the finished set never exceeds its bound, while every one of them has already been scanned and copied. Each hint yields at least one identifier or diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 75d939db..4e793881 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "b823532048f5ef04ea8f8affe65278c66cc73461d0adaff6c3e1b1a412ac1a58", + "compatibility_surface_sha256": "1d1e959d212894c56f833987ec421681467f92361624381f010a9c16a91551f2", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 46964fc8..6d7b211d 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "6c46879d2047f10ce11774fd5c2f074eec47d0ed72783387cde3da0996d4b425" + "sha256": "d9b2f61b61846e534967a5e423f6f1f596c4cb43b9420401c4c8fc4bddafb68d" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "b823532048f5ef04ea8f8affe65278c66cc73461d0adaff6c3e1b1a412ac1a58" + "manifest_sha256": "1d1e959d212894c56f833987ec421681467f92361624381f010a9c16a91551f2" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 230c9673..caa967ca 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -28,6 +28,16 @@ const MAX_CATALOG_NAME_BYTES: usize = 8 * 1024 * 1024; /// vouchers of 20 entries, and a bound below that turned a valid draft into a /// silently empty binding result. pub const MAX_SOURCE_ENTITIES: usize = 40_000; +/// The aggregate a request's source names may occupy, as `MAX_CATALOG_NAME_BYTES` +/// is for the catalog side. The count and per-name bounds do not bound their +/// product any better here than there: 40,000 names of 16,384 characters +/// satisfies both and is 2.6 GB of names alone, before the two folds each +/// entity retains beside its name and the clones this module makes of them. +/// +/// Sixteen mebibytes rather than the catalog's eight, because a request may +/// legitimately name one master many times over — the same ledger on 2,000 +/// vouchers — where a catalog may not. +const MAX_SOURCE_NAME_BYTES: usize = 16 * 1024 * 1024; /// Longest accepted master or source name, in characters. This bounds /// pathological input; it is not a claim about what Tally accepts, and a /// caller with a stricter contract of its own enforces that at its own @@ -121,6 +131,9 @@ pub enum MasterBindingError { CatalogTooLarge, #[error("source entity list exceeded its bound")] TooManySourceEntities, + /// The entity *count* is within bounds but their names together are not. + #[error("source entity names exceeded their aggregate bound")] + SourceNamesTooLarge, #[error("name was blank")] NameBlank, #[error("name exceeded its bound")] @@ -151,6 +164,7 @@ impl MasterBindingError { Self::CatalogDuplicateName => "master_catalog_duplicate_name", Self::CatalogTooLarge => "master_catalog_too_large", Self::TooManySourceEntities => "master_source_entities_too_many", + Self::SourceNamesTooLarge => "master_source_names_too_large", Self::NameBlank => "master_name_blank", Self::NameTooLong => "master_name_too_long", Self::NameUnsafe => "master_name_unsafe", @@ -863,6 +877,28 @@ pub fn bind( if entities.len() > MAX_SOURCE_ENTITIES { return Err(MasterBindingError::TooManySourceEntities); } + // The count bound and the per-name bound do not bound their product, which + // is why the catalog constructor carries an aggregate budget — and the + // source side, which is equally untrusted, carried none. Each entity is + // individually valid at 16,384 characters, and 40,000 of them are two and a + // half gigabytes of names before this function clones a single one of them + // into a report. Refused here, at the boundary where the collection first + // becomes this module's problem, rather than part-way through building the + // report it would otherwise exhaust memory producing. + // + // Both folds are counted, not just the name: they are retained per entity + // and are the same order of size, so counting the name alone would + // under-state what has already been allocated by a factor of three. + let mut source_bytes = 0_usize; + for entity in entities { + source_bytes = source_bytes + .saturating_add(entity.name.len()) + .saturating_add(entity.key.len()) + .saturating_add(entity.binding_key.len()); + if source_bytes > MAX_SOURCE_NAME_BYTES { + return Err(MasterBindingError::SourceNamesTooLarge); + } + } let mut budget = MAX_REPORT_CANDIDATE_BYTES; // Which source keys actually repeat, decided before any of them is bound. // @@ -873,11 +909,28 @@ pub fn bind( // first removes the ordering entirely, and caches only what a second row // will ask for again. // + // Counted on the **whole** determinant of a memo entry, not on the name + // alone. The memo is keyed by the source key *and* the masters the + // identifiers reached, so counting `key` by itself called every hint + // variant of one name repeated: 1,024 singleton variants of `Acme Branch` + // then filled the memo with entries nothing would ask for twice, and a key + // that genuinely repeated behind them could no longer be inserted — the + // stall the memo exists to prevent, reached by a different door than the + // source-order one. + // + // The identifiers are the source-side determinant of those masters: the + // same key with the same identifiers always produces the same memo key + // against a given catalog. The converse does not hold — two different + // identifier sets can reach the same masters — so this counts no pair as + // repeated that is not, and at worst declines to cache one that is. + // // This borrows the keys rather than cloning them, so it costs no more than // the entity list it is counting. - let mut repeats: BTreeMap<&str, usize> = BTreeMap::new(); + let mut repeats: BTreeMap<(&str, &[Identifier]), usize> = BTreeMap::new(); for entity in entities { - *repeats.entry(entity.key.as_str()).or_insert(0) += 1; + *repeats + .entry((entity.key.as_str(), entity.identifiers.as_slice())) + .or_insert(0) += 1; } let repeated = repeats .into_iter() @@ -926,14 +979,17 @@ struct IdentifierEvidence<'a> { exact: Option, /// Every master the entity's identifiers reached. matches: &'a BTreeSet, - /// The largest family skipped rather than expanded, so a withheld listing - /// can still say how many masters share the identifier. + /// A **lower bound** on how many masters share this entity's identifiers, + /// counted without expanding the families that were skipped: the largest + /// skipped family, plus the listed masters that are not in it. Present so a + /// withheld listing can still say how many masters are involved. withheld_holders: usize, } struct SearchMemo<'a> { seen: CandidateMemo, - repeated: BTreeSet<&'a str>, + /// The (source key, identifiers) pairs a second row will ask for again. + repeated: BTreeSet<(&'a str, &'a [Identifier])>, } const MAX_CANDIDATE_MEMO_ENTRIES: usize = 1_024; @@ -962,6 +1018,9 @@ fn bind_one( // withheld family reported `found() == 0` and `listing: "none"`, telling // the operator nothing shares the identifier when hundreds do. let mut withheld_holders = 0_usize; + // The holders of the largest skipped family, kept by reference so the count + // below can ask which listed masters are *not* in it. Nothing is cloned. + let mut largest_withheld: Option<&Vec> = None; for identifier in &entity.identifiers { if let Some(holders) = catalog.by_identifier.get(identifier) { // An identifier held by more masters than a candidate list may show @@ -973,7 +1032,10 @@ fn bind_one( // before the candidate memo is even consulted. if holders.len() > MAX_CANDIDATES_PER_ENTITY { identifier_conflict = true; - withheld_holders = withheld_holders.max(holders.len()); + if holders.len() > withheld_holders { + withheld_holders = holders.len(); + largest_withheld = Some(holders); + } // Skipping the expansion must not skip the *question* the // expansion was asked. `identifier_points_elsewhere` needs one // fact from this set — whether it contains the byte-exact @@ -997,6 +1059,34 @@ fn bind_one( } } + // One family's size is not the size of their union. An entity carrying two + // identifiers — one held by thirty masters and skipped, one reaching a + // thirty-first — reported thirty, because the larger of the two counts + // ignores every master the other identifier listed. + // + // The listed masters that are *not* in the skipped family are disjoint from + // it, so adding them is sound and costs nothing but a lookup: the union is + // never built, which is the whole point of skipping. It stays a **lower + // bound** — two disjoint skipped families are still counted as the larger + // alone — and that is what `Candidates::Withheld` means. Over-counting + // would be worse than under-counting here: two identifiers can be held by + // overlapping families, so summing their sizes would state a number of + // masters that do not exist. + let withheld_holders = match largest_withheld { + Some(family) => { + // `by_identifier` is filled by pushing entry indices in ascending + // order, so each holder list is sorted and a membership test is a + // bisection rather than a scan of up to a whole catalog. + debug_assert!(family.is_sorted(), "holder lists are built in order"); + withheld_holders + + identifier_matches + .iter() + .filter(|index| family.binary_search(index).is_err()) + .count() + } + None => withheld_holders, + }; + // An identifier shared by two masters, and an entity whose identifiers // reach two masters, are the same refusal: the operator has a naming // collision to see, and neither case licenses a choice. @@ -1258,7 +1348,9 @@ fn remembered_candidates( // recomputing it costs, multiplied by the cap — trading a stall for the // memory the aggregate bounds elsewhere exist to prevent. A large result is // cheap to recompute relative to what holding it costs, so it is not held. - let worth_holding = memo.repeated.contains(entity.key.as_str()) + let worth_holding = memo + .repeated + .contains(&(entity.key.as_str(), entity.identifiers.as_slice())) && key.1.len() <= MAX_CANDIDATES_PER_ENTITY && computed.0.len() <= MAX_CANDIDATES_PER_ENTITY; if worth_holding && memo.seen.len() < MAX_CANDIDATE_MEMO_ENTRIES { @@ -1625,7 +1717,17 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro let foreign_content = token .chars() .any(|character| !character.is_ascii() && !DASH_VARIANTS.contains(&character)); - if canonical.len() >= MIN_CODE_IDENTIFIER_CHARS + // The threshold counts the characters that *identify*, not the bytes + // that spell them. `len()` is UTF-8 bytes, and an admitted dash variant + // is three of them, so `AB\u{2013}123` measured 8 and cleared a bound meant + // for eight characters while carrying five alphanumerics — decisive on + // the strength of one punctuation mark. Its ASCII twin `AB-123` reduces + // to `AB123` and is refused, so the same code bound or did not + // depending on which dash the document happened to use. Counting + // letters and digits also closes the padding shape the byte count never + // saw: `AB\u{2013}\u{2013}\u{2013}123` is still five alphanumerics. + let identifying = digits + letters; + if identifying >= MIN_CODE_IDENTIFIER_CHARS && digits >= MIN_CODE_IDENTIFIER_DIGITS && letters >= 2 && !foreign_content @@ -1633,6 +1735,10 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro && !masked && !is_period(token) && !is_masked(&canonical) + // The upper bound stays on the canonical's **bytes**, because it + // bounds what this index stores and clones rather than what + // identifies. A byte cap admits no more characters than it says, + // so it is the conservative half of the pair. && canonical.len() <= MAX_IDENTIFIER_CHARS { identifiers.insert(Identifier { diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 673fb4ef..e7e33922 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -2352,3 +2352,158 @@ fn a_number_typed_into_a_master_name_finds_it_from_any_source_name() { ); } } + +#[test] +fn a_dash_variant_does_not_spell_a_short_code_into_a_decisive_one() { + // The code threshold was `canonical.len()`, which is UTF-8 bytes. An en + // dash is three of them, so `AB–123` measured eight and cleared a bound + // meant for eight *characters* while carrying five alphanumerics. Its + // ASCII twin reduces to `AB123` and is refused, so the same code decided + // or did not on the strength of which dash the document happened to use — + // and the wrong master was reached only in the spelling nobody checks. + let catalog = ledgers(&["Sales AB\u{2013}123", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Purchases AB\u{2013}123"); + assert_eq!( + binding.bound_name(), + None, + "five alphanumerics identifier-bound because one of them was three bytes" + ); + + // Padding is the same defect without the twin to compare against: bytes + // counted the dashes, characters would have counted them too. + let padded = ledgers(&["Sales AB\u{2013}\u{2013}\u{2013}123", "Beta Supply"]); + assert_eq!( + bind_one_name(&padded, "Purchases AB\u{2013}\u{2013}\u{2013}123").bound_name(), + None, + "a code padded with dashes cleared the threshold on its punctuation" + ); + + // The measured shape still binds: `PH-01-AB-00` is eight alphanumerics + // once the separators §9.4d measured are discarded, and it identifies. + let stock = ledgers(&["Sales PH-01-AB-00", "Beta Supply"]); + assert_eq!( + bind_one_name(&stock, "Purchases PH01AB00").bound_name(), + Some("Sales PH-01-AB-00"), + "the fold §9.4d measured stopped working" + ); +} + +#[test] +fn a_withheld_family_counts_the_masters_the_other_identifier_listed_too() { + // The count was the largest skipped family's size, which ignores every + // master the entity's *other* identifiers reached. Thirty masters share + // one number and a thirty-first carries the other: the operator was told + // thirty, and the master that made the two disagree was not in the number. + let mut names = (0..MAX_CANDIDATES_PER_ENTITY + 5) + .map(|index| format!("Shared Party {index:03} (5550007777)")) + .collect::>(); + names.push("Lone Party (5550008888)".to_string()); + let family = MAX_CANDIDATES_PER_ENTITY + 5; + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + + let entity = + SourceEntity::with_identifier_hints(0, "Zeta Holdings", ["5550007777", "5550008888"]) + .expect("valid"); + let binding = bound(&catalog, &[entity]) + .entities() + .first() + .cloned() + .expect("one entity in, one binding out"); + + assert_eq!(reason(&binding), UnboundReason::IdentifierConflict); + let unresolved = binding.unresolved().expect("unbound"); + assert!( + unresolved.candidates.is_incomplete(), + "the family was listed" + ); + assert_eq!( + unresolved.candidates.found(), + family + 1, + "the master the other identifier listed was not counted" + ); + // Still without building the union: the large family is a membership test, + // never an expansion. + assert!( + family + 1 > MAX_CANDIDATES_PER_ENTITY, + "this fixture no longer exercises the skip" + ); +} + +#[test] +fn hint_variants_of_one_name_do_not_crowd_out_a_key_that_repeats() { + // The memo is keyed by the source key *and* the masters the identifiers + // reached, but repetition was counted on the key alone. Every hint variant + // of one name therefore counted as repeated, filled the memo with entries + // nothing asks for twice, and the pair that genuinely repeated behind them + // could no longer be inserted — the same stall as the source-order defect, + // through a different door. + let names = (0..60) + .map(|index| format!("Acme Branch {index:05}")) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + + // Distinct hints, one name: same key, different memo key, each asked once. + let mut entities = (0..MAX_CANDIDATE_MEMO_ENTRIES) + .map(|index| { + SourceEntity::with_identifier_hints( + index, + "Acme Branch", + [format!("5550{index:06}").as_str()], + ) + .expect("valid") + }) + .collect::>(); + // And then a pair that does repeat, four times over. + entities.extend((0..4).map(|offset| { + SourceEntity::with_identifier_hints( + MAX_CANDIDATE_MEMO_ENTRIES + offset, + "Acme Branch", + ["5559999999"], + ) + .expect("valid") + })); + + super::CANDIDATE_SEARCHES.with(|count| count.set(0)); + let report = bound(&catalog, &entities); + let searches = super::CANDIDATE_SEARCHES.with(std::cell::Cell::get); + assert_eq!(report.totals().requested, MAX_CANDIDATE_MEMO_ENTRIES + 4); + assert!( + searches <= MAX_CANDIDATE_MEMO_ENTRIES + 1, + "the repeated pair was searched more than once behind {MAX_CANDIDATE_MEMO_ENTRIES} variants: {searches} searches" + ); +} + +#[test] +fn a_request_within_every_other_bound_is_still_refused_on_its_total_size() { + // The count bound and the per-name bound do not bound their product. Each + // of these names is valid, and the list is well inside the entity count; + // together they are more than the aggregate the catalog side has always + // had, and this module would have cloned them into a report first. + // One long token rather than many short ones: the aggregate is what this + // test is about, and a name of 1,800 words would spend the whole test in + // identifier extraction proving nothing extra. + let name = format!("Zeta{}", "z".repeat(MAX_NAME_CHARS - 5)); + assert!( + name.chars().count() < MAX_NAME_CHARS, + "each name must stay individually valid" + ); + let catalog = ledgers(&["Alpha Supply", "Beta Supply"]); + let entities = (0..1_200) + .map(|index| SourceEntity::new(index, &name).expect("each name is valid alone")) + .collect::>(); + assert!( + entities.len() < MAX_SOURCE_ENTITIES, + "the count bound must not be what refuses this" + ); + assert_eq!( + bind(&catalog, &entities), + Err(MasterBindingError::SourceNamesTooLarge) + ); + + // And the bound does not refuse an ordinary request: the same names, well + // under the aggregate, bind as before. + let ordinary = (0..40) + .map(|index| SourceEntity::new(index, &name).expect("valid")) + .collect::>(); + assert!(bind(&catalog, &ordinary).is_ok()); +} From 03685631dd9c86df3517dd62852f4dbf1b56ebfd Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:18:59 +0530 Subject: [PATCH 42/75] fix(source-draft): carry the candidate state, do not rebuild it downstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There are two ways a candidate listing comes back empty beside a nonzero count, and they call for opposite things from the operator: a family the binder declined to slice, where a fuller source name helps, and a report that ran out of room, where nothing the operator writes will. The screen told them apart by the refusal reason, which named only one of the two withheld shapes — so a family withheld under `IdentifierConflict` reached the budget sentence and said the report had run out of room when it had not. Adding the second reason to that check would be the same defect with a longer condition. The state travels instead: - `Candidates::listing()` returns the state as the word `#[serde(tag = "listing")]` already puts on the wire, and a test serializes every variant and asserts the accessor against the tag, so the DTO reader and the JSON reader cannot come to disagree. - The DTO's `candidates_truncated` is replaced by `candidate_listing`, not joined by it: the boolean was `is_incomplete()`, true for both withheld and truncated, which was the conflation itself. - The screen branches on the carried word. The refusal reason still chooses the lead sentence, which is what it is for. The agent surface already derived its own flag from `listing` and was correct throughout; only the desktop projection flattened the state and then tried to rebuild it. Also reseals the compatibility surface against the final bytes. The previous reseal ran before `cargo fmt`, which rewrote a pinned file underneath it. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 10 +++---- .../source-draft-capture-bindings.json | 6 ++-- scripts/source-draft-screen.test.tsx | 22 +++++++------- .../bridge-tally-core/src/master_binding.rs | 17 +++++++++++ .../src/master_binding_tests.rs | 30 +++++++++++++++++++ src-tauri/src/source_draft/catalog.rs | 22 +++++++++----- src/SourceDraftScreen.tsx | 18 +++++++---- src/source-draft-types.ts | 5 +++- 9 files changed, 97 insertions(+), 35 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 02a0f15b..b5be0cb2 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "001aaddcf136afbb214c1183118c497b2bc9a3c6f513ac99a80e6bf511371593", + "compatibility_surface_sha256": "0cdcedca97d0ba3c053bf58fd3f91722117a9dc88ee21449ffdbbeb4df88aff6", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 06546800..4601fb88 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "6c46879d2047f10ce11774fd5c2f074eec47d0ed72783387cde3da0996d4b425" + "sha256": "6a1008c5d7b17cbe4e8aef2d99c3eff8c2ea71775da158ed7944059ede6a5514" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -583,7 +583,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "b17e74f7ea86934281ece0216df5fdc8d453f513e3662eec8aaaccd71c0316e0" + "sha256": "5cd0273bd2d7c399af0bef0c6a5a8c3098fd82404d8a31c8a6e2fd1c7b67c60d" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -723,7 +723,7 @@ }, { "path": "src/SourceDraftScreen.tsx", - "sha256": "a48fd18073d0dca0af0ebd484ae2884d5e3a67c88bb935878c657c60c86ec28b" + "sha256": "1f529944e342c76e2cb46a61c0011fb624e4a155d6d41cc1d04a70c5e4330f03" }, { "path": "src/TallyReadinessFlow.tsx", @@ -779,7 +779,7 @@ }, { "path": "src/source-draft-types.ts", - "sha256": "cc7885a1f6942a63a7768f207a21198b94e601c3413fa3600f22ac05b5664939" + "sha256": "a2e32dd9a01d7aa0f0fb6c309bb648e6954068d926f5533f56f71bbffa7c2a86" }, { "path": "src/source-draft.css", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "001aaddcf136afbb214c1183118c497b2bc9a3c6f513ac99a80e6bf511371593" + "manifest_sha256": "0cdcedca97d0ba3c053bf58fd3f91722117a9dc88ee21449ffdbbeb4df88aff6" } \ No newline at end of file diff --git a/scripts/fixtures/source-draft-capture-bindings.json b/scripts/fixtures/source-draft-capture-bindings.json index f0fd9ca7..bc4e2230 100644 --- a/scripts/fixtures/source-draft-capture-bindings.json +++ b/scripts/fixtures/source-draft-capture-bindings.json @@ -4,8 +4,8 @@ "bound_basis": "exact_name", "bound_target": "Cash", "candidate_count": 0, + "candidate_listing": "none", "candidates": [], - "candidates_truncated": false, "entry_position": 1, "row_position": 1, "unbound_reason": null @@ -14,8 +14,8 @@ "bound_basis": "normalized_name", "bound_target": "WR2 Sales", "candidate_count": 0, + "candidate_listing": "none", "candidates": [], - "candidates_truncated": false, "entry_position": 2, "row_position": 1, "unbound_reason": null @@ -24,10 +24,10 @@ "bound_basis": null, "bound_target": null, "candidate_count": 1, + "candidate_listing": "listed", "candidates": [ "Profit & Loss A/c" ], - "candidates_truncated": false, "entry_position": 3, "row_position": 1, "unbound_reason": "master_binding_near_miss" diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index 8f057da2..fa1ec003 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -278,7 +278,7 @@ test("groups and lists a catalogue of realistic size without losing the narrowin unbound_reason: null, candidates: [], candidate_count: 0, - candidates_truncated: false, + candidate_listing: "listed", }], }; mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(large); @@ -324,7 +324,7 @@ test("keeps the binding result visible beside a saved target nobody has re-read" unbound_reason: "master_binding_near_miss", candidates: ["Existing target"], candidate_count: 1, - candidates_truncated: false, + candidate_listing: "listed", }], }; mocks.invoke.mockResolvedValueOnce(savedTarget).mockResolvedValueOnce(refused); @@ -414,7 +414,7 @@ test("lists the bound ledger first without selecting it, and keeps the whole cat unbound_reason: null, candidates: [], candidate_count: 0, - candidates_truncated: false, + candidate_listing: "listed", }], }; mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(boundCatalog); @@ -458,7 +458,7 @@ test("names the refusal when the name and the identifier point at different ledg unbound_reason: "master_binding_identifier_name_conflict", candidates: ["Alpha placeholder", "Gamma placeholder"], candidate_count: 2, - candidates_truncated: false, + candidate_listing: "listed", }], }; mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(conflictCatalog); @@ -491,7 +491,7 @@ test("distinguishes the two other refusals that are not weak matches", async () unbound_reason: reason, candidates: ["Alpha placeholder", "Gamma placeholder"], candidate_count: 2, - candidates_truncated: false, + candidate_listing: "listed", }], }); const host = document.createElement("div"); @@ -562,7 +562,7 @@ test("a refusal reason survives the candidate listing being dropped", async () = unbound_reason: "master_binding_identifier_name_conflict", candidates: [], candidate_count: 6, - candidates_truncated: true, + candidate_listing: "truncated", }], }); const host = document.createElement("div"); @@ -592,7 +592,7 @@ test("choosing a target stops the screen saying nothing was chosen, without hidi unbound_reason: "master_binding_identifier_name_conflict", candidates: ["Alpha placeholder", "Gamma placeholder"], candidate_count: 2, - candidates_truncated: false, + candidate_listing: "listed", }], }; // Selecting a target goes through the apply path, which re-reads: the draft @@ -640,7 +640,7 @@ test("an empty list because the report ran out of room is not a family the name unbound_reason: "master_binding_near_miss", candidates: [], candidate_count: 7, - candidates_truncated: true, + candidate_listing: "truncated", }], }; mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(exhaustedCatalog); @@ -667,7 +667,7 @@ test("lists candidates first for a near miss and states that nothing was chosen" unbound_reason: "master_binding_near_miss", candidates: ["Alpha placeholder", "Gamma placeholder"], candidate_count: 2, - candidates_truncated: false, + candidate_listing: "listed", }], }; mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(nearMissCatalog); @@ -699,7 +699,7 @@ test("reports a truncated candidate list truthfully and falls back to the flat c unbound_reason: "master_binding_near_miss", candidates: ["Alpha placeholder"], candidate_count: 40, - candidates_truncated: true, + candidate_listing: "truncated", }], }; mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(truncatedCatalog); @@ -728,7 +728,7 @@ test("a source line that separates no ledger says so instead of counting nothing unbound_reason: "master_binding_no_discriminating_candidate", candidates: [], candidate_count: 120, - candidates_truncated: true, + candidate_listing: "truncated", }], }; mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(familyCatalog); diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 230c9673..6bf9d5b8 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -316,6 +316,23 @@ impl Candidates { } } + /// Which of the four states this is, as the one word the serialized form + /// already tags it with. + /// + /// A projection that flattens this enum needs the state itself, not a + /// reconstruction of it: inferring "withheld" from an empty listing beside + /// a nonzero count told an operator the report had run out of room when it + /// had deliberately declined to slice a family. The word is the same one + /// `#[serde(tag = "listing")]` emits, and a test holds the two together. + pub fn listing(&self) -> &'static str { + match self { + Self::None => "none", + Self::Listed { .. } => "listed", + Self::Truncated { .. } => "truncated", + Self::Withheld { .. } => "withheld", + } + } + /// Masters found before any truncation or withholding. pub fn found(&self) -> usize { match self { diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 673fb4ef..2f5970ab 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -2352,3 +2352,33 @@ fn a_number_typed_into_a_master_name_finds_it_from_any_source_name() { ); } } + +#[test] +fn the_listing_word_is_the_one_the_wire_carries() { + // `listing()` exists so a projection need not reconstruct the state from + // an empty list and a count. If it drifted from the serde tag, a consumer + // reading the DTO and a consumer reading the JSON would disagree about the + // same binding — so they are asserted against each other, not assumed. + let candidate = Candidate { + catalog_name: "Alpha Supply".to_string(), + rule: CandidateRule::ExactName, + }; + for candidates in [ + Candidates::None, + Candidates::Listed { + listed: vec![candidate.clone()], + }, + Candidates::Truncated { + listed: vec![candidate], + found: 9, + }, + Candidates::Withheld { found: 9 }, + ] { + let json = serde_json::to_value(&candidates).expect("candidates serialize"); + assert_eq!( + json.get("listing").and_then(serde_json::Value::as_str), + Some(candidates.listing()), + "the accessor and the wire tag disagree about {candidates:?}" + ); + } +} diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index 3f119b3d..9a9d9521 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use bridge_tally_core::master_binding::{ - self, BindingBasis, BindingStatus, MasterCatalog, MasterClass, SourceEntity, + self, BindingBasis, BindingStatus, Candidates, MasterCatalog, MasterClass, SourceEntity, }; use bridge_tally_protocol::{StandardLedgerCatalog, StandardLedgerCatalogBinding}; @@ -82,7 +82,12 @@ pub(crate) struct SourceDraftCatalogBinding { pub(crate) unbound_reason: Option<&'static str>, pub(crate) candidates: Vec, pub(crate) candidate_count: usize, - pub(crate) candidates_truncated: bool, + /// Which of the four candidate states this is, in the word the core type + /// already tags its serialized form with. Carried rather than inferred: an + /// empty listing beside a nonzero count is two different results — a family + /// deliberately not sliced, and a report that ran out of room — and they + /// call for opposite things from the operator. + pub(crate) candidate_listing: &'static str, } #[derive(Debug, Serialize)] @@ -187,7 +192,7 @@ fn source_entry_bindings( unbound_reason: None, candidates: Vec::new(), candidate_count: 0, - candidates_truncated: false, + candidate_listing: Candidates::None.listing(), }, BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved) => { SourceDraftCatalogBinding { @@ -196,11 +201,12 @@ fn source_entry_bindings( bound_target: None, bound_basis: None, unbound_reason: Some(unresolved.reason.safe_reason_code()), - // The screen distinguishes the three cases from - // `candidate_count` against an empty list and is tested - // on each, so the DTO stays flat and this projection is - // the only place the typed shape is flattened. - candidates_truncated: unresolved.candidates.is_incomplete(), + // The state travels; it is not reconstructed on the + // other side. Deriving it from an empty list and a + // count could not tell a withheld family from an + // exhausted budget, and told the operator the report + // had run out of room when it had declined to slice. + candidate_listing: unresolved.candidates.listing(), candidates: unresolved .candidates .listed() diff --git a/src/SourceDraftScreen.tsx b/src/SourceDraftScreen.tsx index 418320bc..5302aee6 100644 --- a/src/SourceDraftScreen.tsx +++ b/src/SourceDraftScreen.tsx @@ -137,15 +137,21 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: const shown = binding.candidates.length; if (shown === 0) { // Two different facts arrive here with an empty list and a nonzero count, - // and they call for opposite actions. A withheld family is the binder + // and they call for opposite actions. A **withheld** family is the binder // refusing to print an arbitrary slice of ledgers this name cannot separate // — slicing put the right one out of view about a third of the time across // sixteen live catalogues: `TALLY_PROTOCOL_REFERENCE.md` §9.4c states the // rule, `TEST_CORPUS.md` §9.1 carries the counts and their scope — and a - // fuller source name fixes it. Budget exhaustion is - // this report running out of room on earlier rows; the source name is fine - // and nothing the operator writes here would change it. - if (binding.unbound_reason === "master_binding_no_discriminating_candidate") { + // fuller source name fixes it. A **truncated** listing is this report + // running out of room on earlier rows; the source name is fine and nothing + // the operator writes here would change it. + // + // Which one it is now arrives in the DTO. It used to be inferred from the + // refusal reason, which named only the one withheld shape this screen knew + // about; a family withheld under `identifier_conflict` reached the budget + // sentence and told the operator the report had run out of room when it + // had not. + if (binding.candidate_listing === "withheld") { return `This source line matches ${binding.candidate_count} existing ledgers and tells them apart from none of them, so none is listed. Use a fuller source name, or choose from the full list of ${total}.`; } // Why it refused survives the listing being dropped. Returning only the @@ -153,7 +159,7 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: // had just been fixed to show — the same defect, one branch over. return `${catalogRefusalLead(binding.unbound_reason)} ${binding.candidate_count} existing ledgers are involved, but this report ran out of room to list them. Choose from the full list of ${total}.`; } - const listed = binding.candidates_truncated ? `${shown} of ${binding.candidate_count}` : `${shown}`; + const listed = binding.candidate_listing === "truncated" ? `${shown} of ${binding.candidate_count}` : `${shown}`; const lead = catalogRefusalLead(binding.unbound_reason); return `${lead} Nothing is chosen; ${listed} possible ${shown === 1 ? "ledger is" : "ledgers are"} listed first, and the full list of ${total} follows.`; } diff --git a/src/source-draft-types.ts b/src/source-draft-types.ts index 73e458c8..b75b07ac 100644 --- a/src/source-draft-types.ts +++ b/src/source-draft-types.ts @@ -77,7 +77,10 @@ export type SourceDraftCatalogBinding = { unbound_reason: string | null; candidates: string[]; candidate_count: number; - candidates_truncated: boolean; + /** "none" | "listed" | "truncated" | "withheld" — the core's own word for + * this state, carried rather than inferred: an empty list beside a nonzero + * count is a withheld family or an exhausted budget, and they differ. */ + candidate_listing: string; }; export type SourceDraftCatalogTargets = { From 6dd44c677ac2690f94cbc5a7d3041b6d280922ce Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:20:36 +0530 Subject: [PATCH 43/75] test(source-draft): cover the second withheld shape, and stop calling it truncated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanical rewrite of `candidates_truncated` into `candidate_listing` turned the `no_discriminating_candidate` fixture into `"truncated"` — which is the exact conflation this change removes, reproduced by the change itself. `a family withheld under a different reason is not reported as a full report` is the shape the review found: a family withheld under `identifier_conflict`, which the reason-based inference did not recognise and which therefore reached the budget sentence. Reverting the branch to the reason check fails it and nothing else. Co-Authored-By: Claude Opus 5 --- scripts/source-draft-screen.test.tsx | 35 +++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index fa1ec003..bc23da5c 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -712,6 +712,39 @@ test("reports a truncated candidate list truthfully and falls back to the flat c root.unmount(); }); +test("a family withheld under a different reason is not reported as a full report", async () => { + // The second withheld shape. An identifier held by more masters than a + // candidate list may show is withheld under `identifier_conflict`, not under + // `no_discriminating_candidate` — and inferring the state from the reason + // knew only the latter, so this one fell to the budget sentence and told the + // operator the report had run out of room when it had declined to slice. + // The two sentences give opposite advice, so this is not a wording defect. + const withheldFamily = { + ...catalog, + targets: ["DN Party 001", "DN Party 002", "DN Party 003"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: null, + bound_basis: null, + unbound_reason: "master_binding_identifier_conflict", + candidates: [], + candidate_count: 30, + candidate_listing: "withheld", + }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(withheldFamily); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + + expect(host.textContent).toContain("matches 30 existing ledgers and tells them apart from none of them, so none is listed"); + expect(host.textContent).not.toContain("ran out of room"); + root.unmount(); +}); + test("a source line that separates no ledger says so instead of counting nothing", async () => { // The state the live measurement made necessary: the name reaches a whole // family and tells none of them apart, so listing an arbitrary slice would @@ -728,7 +761,7 @@ test("a source line that separates no ledger says so instead of counting nothing unbound_reason: "master_binding_no_discriminating_candidate", candidates: [], candidate_count: 120, - candidate_listing: "truncated", + candidate_listing: "withheld", }], }; mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(familyCatalog); From 6663452d19ed0a71b953ff1566c9682a0b215f15 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:42:06 +0530 Subject: [PATCH 44/75] fix(source-draft): a choice settles what the operator wants, not a disagreement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The summary was suppressed whenever a `bound_target` merely existed beside a current-session selection. So choosing B where the capture defended A left nothing on screen saying the two differed — while the line beside it said the target had been re-read and bound, which reads as agreement. The comparison is against the target now, not against whether one exists. Identical: silence, which is what the suppression is for. Different: the binding's own ledger is named, and the operator is told their choice stands. Both halves are tested, because a suppression and a message are each other's control: returning `null` unconditionally fails the disagreement test, and a message on agreement would fail the other. Co-Authored-By: Claude Opus 5 --- scripts/source-draft-screen.test.tsx | 85 ++++++++++++++++++++++++++++ src/SourceDraftScreen.tsx | 23 +++++--- 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index bc23da5c..1b766bb5 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -300,6 +300,91 @@ test("groups and lists a catalogue of realistic size without losing the narrowin root.unmount(); }); +test("says so when the operator chooses a different ledger from the one binding matched", async () => { + // A choice settles what the operator wants; it does not settle a + // disagreement. Suppressing the summary whenever a `bound_target` merely + // existed meant choosing B where the capture defended A left nothing on + // screen saying the two differed — while the adjacent line said the target + // had been re-read and bound, which reads as agreement. + const twoTargets = { + ...catalog, + targets: ["Bound target", "Other target"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: "Bound target", + bound_basis: "exact_name", + unbound_reason: null, + candidates: [], + candidate_count: 0, + candidate_listing: "none", + }], + }; + const applied = { + ...draft, + revision: 2, + rows: draft.rows.map((item, index) => index === 0 ? { + ...item, + proposal: { ...item.proposal, entries: [{ ...item.proposal.entries[0], ledger: "Other target" }] }, + } : item), + current_catalog_bindings: [{ row_position: 1, entry_position: 1 }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(twoTargets).mockResolvedValueOnce(applied); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + + const target = host.querySelector("#source-draft-1-entry-0-ledger")!; + await act(async () => setValue(target, "Other target")); + expect(target.value).toBe("Other target"); + expect(host.textContent).toContain("This current-session target was re-read and bound."); + // The disagreement survives the choice, and says which ledger it was about. + expect(host.textContent).toContain("Automatic binding matched Bound target for this source line, not the ledger chosen here."); + root.unmount(); +}); + +test("says nothing extra when the operator chooses the ledger binding matched", async () => { + // The other half: agreement is silence. A summary repeating the binding + // beside an identical choice is noise, and it is why the suppression exists. + const agreeing = { + ...catalog, + targets: ["Bound target", "Other target"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: "Bound target", + bound_basis: "exact_name", + unbound_reason: null, + candidates: [], + candidate_count: 0, + candidate_listing: "none", + }], + }; + const applied = { + ...draft, + revision: 2, + rows: draft.rows.map((item, index) => index === 0 ? { + ...item, + proposal: { ...item.proposal, entries: [{ ...item.proposal.entries[0], ledger: "Bound target" }] }, + } : item), + current_catalog_bindings: [{ row_position: 1, entry_position: 1 }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(agreeing).mockResolvedValueOnce(applied); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + + const target = host.querySelector("#source-draft-1-entry-0-ledger")!; + await act(async () => setValue(target, "Bound target")); + expect(target.value).toBe("Bound target"); + expect(host.textContent).not.toContain("Automatic binding matched"); + root.unmount(); +}); + test("keeps the binding result visible beside a saved target nobody has re-read", async () => { // `entry.ledger` alone is not a choice. A saved target from an earlier // session leaves `catalogSelections` empty, the control shows nothing diff --git a/src/SourceDraftScreen.tsx b/src/SourceDraftScreen.tsx index 5302aee6..b7ac558d 100644 --- a/src/SourceDraftScreen.tsx +++ b/src/SourceDraftScreen.tsx @@ -105,20 +105,29 @@ function narrowedTargets(binding: SourceDraftCatalogBinding | null) { /// name points at one ledger and the number inside it points at another — look /// like an ordinary weak match. That is the one case where the operator has /// real information to act on, and it was the case being hidden. -function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: number, selected: boolean) { +function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: number, selected: string | null) { if (!binding) return null; - if (selected) { + if (selected !== null) { // The operator has chosen. Saying "nothing is chosen" beside their choice // is simply false, and it contradicted the adjacent line telling them the // target was re-read and bound. // - // The *reason* still matters though, and is not hidden: an identifier and a + // What a choice does *not* do is settle a disagreement. A binding that + // matched a different ledger is exactly the fact the operator would want to + // see beside their own selection, and taking a boolean here hid it: any + // bound target suppressed the summary, so choosing B where the capture + // defended A left nothing on screen saying so. The comparison is against + // the target, not against whether one exists. + if (binding.bound_target) { + return binding.bound_target === selected + ? null + : `Automatic binding matched ${displayCatalogTarget(binding.bound_target)} for this source line, not the ledger chosen here. Your choice stands; nothing has been changed for you.`; + } + // The *reason* still matters too, and is not hidden: an identifier and a // name pointing at different ledgers is grounds to check a choice, not // something that stops being true once one is made. So the refusal survives // in the past tense, without the guidance that no longer applies. - return binding.bound_target - ? null - : `Automatic binding did not resolve this line. ${catalogRefusalLead(binding.unbound_reason)}`; + return `Automatic binding did not resolve this line. ${catalogRefusalLead(binding.unbound_reason)}`; } if (binding.bound_target) { // `identifier` covers both shapes the binder extracts — a numeric run and @@ -627,7 +636,7 @@ function SourceDraftEditor({ row, disabled, catalog, catalogSelections, catalogI const selectedKey = catalogSelectionKey(row.position, index + 1); const selectedNow = catalogSelections[selectedKey] === entry.ledger; const bindingSummary = catalog - ? catalogBindingSummary(binding, catalog.targets.length, selectedNow) + ? catalogBindingSummary(binding, catalog.targets.length, selectedNow ? entry.ledger ?? null : null) : null; return

Source line {index + 1}{sourceEntryLabel(row.entries[index] ?? { position: index, source_ledger: "", source_amount: "", source_polarity: "" })}

From 404c2e3308c3c6e32fd948c76a52ddf11ea68526 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:43:07 +0530 Subject: [PATCH 45/75] fix(master-binding): the separator cuts both ways, and the memo excluded its own reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **A separator reveals a date and hides two.** The date guard ran on the canonical only, where removing `-` turns `2025-09-11` into a recognisable `20250911`. It also turns `DATED20250911-20250912` into one sixteen-digit run that reads as no date at all, and `is_period` does not see it either, because its eight-digit case admits a year followed by a year and `0911` is neither. A date range identified, and a period label is the single thing two unrelated masters most reliably share. The guard now runs on both spellings; neither subsumes the other. What is *not* fixed is written above `carries_plausible_date`: a partly separated range still escapes all three guards, and the obvious generalisation was considered and rejected, because refusing every sixteen-digit account number containing a date-shaped window throws away the identifiers this module exists to use. **The memo refused to hold the searches it existed for.** `worth_holding` excluded results larger than the candidate cap, reasoning that a large result costs what recomputing it costs — which assumes it is recomputed once. A name reaching a large family through shared tokens, repeated across a draft, is the case where it is recomputed per row, and it was exactly the case excluded. `collect_candidates` now sorts into the caller's order and truncates, so every result is holdable and the per-row allocation of a whole family is gone; `found` is still the full union, so the count is unchanged. The ordering is one function used by both the truncation and the sort, because a disagreement between them would drop a candidate silently. The now-vacuous size test is removed rather than left as a trap. **The candidates came from a different index than the reason.** `NameAmbiguous` is decided on the resolving fold and the list was built from the wide one, which is not always coarser: `master_identity_key` replaces `-` but not `/`, so `AB/CD` and `AB CD` are one master to `verified_fold` and two to it. The operator was shown an ambiguity with a complete-looking list of one. The resolving-key holders are offered too — correct however the two folds relate, where widening the wide fold would fix one example and leave the list assembled from the wrong index. **A request refusable from its own arguments spent two live reads first.** The tool schema admits names the core refuses. Parsing now happens before `verified_company` and before the catalogue read, and `master_report` takes parsed entities so the ordering is not a convention to remember. Each fix has a test whose control is the fix reverted. The last one's control is the endpoint: nothing is listening on it, so a read attempted before parsing comes back as a failure to connect rather than `master_name_unsafe`. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 +- .../bridge-tally-core/src/master_binding.rs | 104 +++++++++++++++--- .../src/master_binding_tests.rs | 97 ++++++++++++++++ src-tauri/src/agent_failure_tests.rs | 44 ++++++++ src-tauri/src/agent_import.rs | 62 +++++++---- src-tauri/src/agent_import_tests.rs | 11 +- 7 files changed, 282 insertions(+), 44 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 4e793881..ddecfadf 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "1d1e959d212894c56f833987ec421681467f92361624381f010a9c16a91551f2", + "compatibility_surface_sha256": "e7263d3dd4bdbdbc8b42fd1685a0cd3c25a29ae71e25df5740deeb73f2701c57", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 6d7b211d..f351d537 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "d9b2f61b61846e534967a5e423f6f1f596c4cb43b9420401c4c8fc4bddafb68d" + "sha256": "fee1d066c63446dbadf4efb7a9b795399899b0b7780cd8ad2a234de4b2f1dbf0" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -331,7 +331,7 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "b8d230ab56b0013d42a65788d7b688387d0f6cdd10faea40b7d1e23e0706769b" + "sha256": "c186d5d8618ce1b92ff02cf4451abf5e76eb435647e3c9ecae6e5aaea6210ba5" }, { "path": "src-tauri/src/agent_ledgers.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "1d1e959d212894c56f833987ec421681467f92361624381f010a9c16a91551f2" + "manifest_sha256": "e7263d3dd4bdbdbc8b42fd1685a0cd3c25a29ae71e25df5740deeb73f2701c57" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index caa967ca..a7d732ad 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1275,13 +1275,7 @@ fn unresolved_from( budget: &mut usize, ) -> BindingStatus { let mut ordered = candidates; - ordered.sort_by(|left, right| { - left.1.rank().cmp(&right.1.rank()).then_with(|| { - catalog.entries[left.0] - .name - .cmp(&catalog.entries[right.0].name) - }) - }); + ordered.sort_by(|left, right| candidate_order(catalog, left, right)); // The variant is derived here, in one place, from the same facts that chose // the reason — so "empty" can never mean something the variant does not say. let candidates = if ordered.is_empty() { @@ -1344,15 +1338,18 @@ fn remembered_candidates( } let computed = collect_candidates(catalog, entity, identifier_matches); // Entry *count* alone does not bound a memo whose keys and values are - // themselves collections. Caching a large result would retain exactly what - // recomputing it costs, multiplied by the cap — trading a stall for the - // memory the aggregate bounds elsewhere exist to prevent. A large result is - // cheap to recompute relative to what holding it costs, so it is not held. + // themselves collections, so the key is still size-tested. The **value** is + // not, any more: `collect_candidates` now returns at most + // `MAX_CANDIDATES_PER_ENTITY` candidates, so every result is small enough + // to hold. Refusing to hold large ones read as prudence and was the + // opposite — it excluded from the memo exactly the expensive repeated + // search the memo exists for, and a name reaching twenty thousand masters + // through shared tokens re-ran it once per row. + debug_assert!(computed.0.len() <= MAX_CANDIDATES_PER_ENTITY); let worth_holding = memo .repeated .contains(&(entity.key.as_str(), entity.identifiers.as_slice())) - && key.1.len() <= MAX_CANDIDATES_PER_ENTITY - && computed.0.len() <= MAX_CANDIDATES_PER_ENTITY; + && key.1.len() <= MAX_CANDIDATES_PER_ENTITY; if worth_holding && memo.seen.len() < MAX_CANDIDATE_MEMO_ENTRIES { memo.seen.insert(key, computed.clone()); } @@ -1434,6 +1431,27 @@ fn collect_candidates( for index in catalog.by_key.get(&entity.key).into_iter().flatten() { offer(*index, CandidateRule::NormalizedEqual); } + // The masters that *caused* a name ambiguity are the ones the resolving + // fold collided, and they are not always reachable from the wide key: the + // wide fold replaces `-` but not `/`, so `AB/CD` and `AB CD` are one master + // to `verified_fold` and two to `master_identity_key`. Listing only the + // wide key's holders reported an ambiguity with a complete-looking list of + // one, omitting the master the operator was being asked to choose between. + // + // Taking the holders from the index the reason was decided on is right + // regardless of how the two folds relate. Widening `master_identity_key` to + // fold `/` as well would fix this one example, change prefix families, + // token sets and memo keys across the whole module on the strength of it, + // and still leave the candidate list assembled from a key that is not the + // one the ambiguity was found in. + for index in catalog + .by_binding_key + .get(&entity.binding_key) + .into_iter() + .flatten() + { + offer(*index, CandidateRule::NormalizedEqual); + } if withheld.is_empty() { for index in &extending { offer(*index, CandidateRule::CatalogPrefix); @@ -1489,7 +1507,42 @@ fn collect_candidates( .len(); // Indices, not names — cloning every match before the cap and the budget // discarded most of the work, once per entry of an admitted draft. - (best.into_iter().collect(), found) + let mut listed = best.into_iter().collect::>(); + // Capped **here**, in the order the caller sorts into, rather than + // downstream after the whole union has been returned. Two reasons, and the + // second is the one that was biting: + // + // 1. The return value was unbounded. A name reaching a large family through + // shared tokens built a vector of that whole family per source row, of + // which at most `MAX_CANDIDATES_PER_ENTITY` survive. + // 2. The memo refused to hold a result larger than that cap, so exactly the + // expensive repeated search was the one never remembered, and a draft + // repeating that name re-ran it once per row. Bounding the result makes + // every result cacheable, which is what the memo was for. + // + // `found` is computed above from the full union, so the count an operator + // sees is unaffected by the cap; only the listing is. + listed.sort_by(|left, right| candidate_order(catalog, left, right)); + listed.truncate(MAX_CANDIDATES_PER_ENTITY); + (listed, found) +} + +/// How candidates are ordered wherever they are ordered: by the rule that +/// reached them, then by the master's name. +/// +/// Defined once because `collect_candidates` truncates in this order and +/// `unresolved_from` sorts in it, and a disagreement between the two would +/// silently drop a candidate that should have been listed. +fn candidate_order( + catalog: &MasterCatalog, + left: &(usize, CandidateRule), + right: &(usize, CandidateRule), +) -> std::cmp::Ordering { + left.1.rank().cmp(&right.1.rank()).then_with(|| { + catalog.entries[left.0] + .name + .cmp(&catalog.entries[right.0].name) + }) } /// A name is retained **verbatim**, on both sides. @@ -1731,7 +1784,17 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro && digits >= MIN_CODE_IDENTIFIER_DIGITS && letters >= 2 && !foreign_content + // A separator works in both directions, so the date guard has to + // run on both spellings. Removing `-` can *reveal* a date — + // `2025-09-11` becomes `20250911` — and it can just as easily + // *hide* two: `DATED20250911-20250912` fuses into one sixteen-digit + // run that reads as no date at all, and `is_period` does not see it + // either, because its eight-digit case admits a year followed by a + // year and `0911` is neither. A date range then identified, and a + // period label is the one thing two unrelated masters most reliably + // share. && !carries_plausible_date(&canonical) + && !carries_plausible_date(token) && !masked && !is_period(token) && !is_masked(&canonical) @@ -1960,6 +2023,19 @@ fn part_reads_as_period(canonical: &str, any_number: &mut bool) -> bool { /// because its eight-digit case admits a year followed by a year and `0911` is /// neither. Two unrelated ledgers sharing a fused date label then bound to each /// other on it. +/// +/// **This is spelling-specific, deliberately, and it is not the general rule.** +/// It is called on both the raw token and its canonical form because removing a +/// separator can reveal a date and can equally hide two, and those are two +/// different maximal-run decompositions. A partly separated range such as +/// `2025-0911-2025-0912` still escapes all three date guards, and the obvious +/// generalization — scanning every eight-digit *window* of the token's +/// concatenated digits — was considered and rejected: a sixteen-digit account +/// number containing a date-shaped window would then be refused, and that is a +/// strong identifier being thrown away to catch a label. Erring toward refusal +/// is right when the alternative is a wrong-party bind; it is not right when it +/// costs the identifiers this module exists to use. If a better rule is found, +/// it belongs here, replacing all three. fn carries_plausible_date(canonical: &str) -> bool { canonical .split(|character: char| !character.is_ascii_digit()) diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index e7e33922..095d2edc 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -2507,3 +2507,100 @@ fn a_request_within_every_other_bound_is_still_refused_on_its_total_size() { .collect::>(); assert!(bind(&catalog, &ordinary).is_ok()); } + +#[test] +fn a_date_range_fused_by_its_own_separator_is_still_dates() { + // The separator works in both directions. Removing `-` reveals a date in + // `2025-09-11`, and hides two in `DATED20250911-20250912`: the canonical + // carries one sixteen-digit run that reads as no date, and `is_period` does + // not see it either, because its eight-digit case admits a year followed by + // a year and `0911` is neither. A period label is the single thing two + // unrelated masters most reliably share, so identifying on one is the + // wrong-party bind this guard exists to prevent. + let catalog = ledgers(&["Sales DATED20250911-20250912", "Beta Supply"]); + assert_eq!( + bind_one_name(&catalog, "Purchases DATED20250911-20250912").bound_name(), + None, + "a fused date range identified two unrelated masters with each other" + ); + + // The direction the canonical check exists for still holds: a date written + // with separators is a date once they are removed. + let punctuated = ledgers(&["Sales 2025-09-11", "Beta Supply"]); + assert_eq!( + bind_one_name(&punctuated, "Purchases 2025-09-11").bound_name(), + None, + "a punctuated date identified" + ); + + // And a genuine code still identifies, so the guard has not swallowed the + // rule it guards. + let coded = ledgers(&["Sales AB-123456", "Beta Supply"]); + assert_eq!( + bind_one_name(&coded, "Purchases AB-123456").bound_name(), + Some("Sales AB-123456"), + "the identifier rule stopped working" + ); +} + +#[test] +fn the_expensive_repeated_search_is_the_one_the_memo_keeps() { + // `worth_holding` refused to cache a result larger than the candidate cap, + // which read as prudence and excluded exactly the search the memo exists + // for: a name reaching a large family through shared tokens re-ran it once + // per row. The search now returns a capped list and the full count, so + // every result is small enough to hold. + // Forty masters carry one distinctive token, in a catalog of four hundred. + // Forty is exactly the common-token limit rather than over it, so the token + // still discriminates and the search returns all forty — more than the + // candidate cap, which is what made the old memo refuse to hold it. The + // token sits at the end of each name so this reaches the shared-token rule + // rather than the prefix family, which withholds instead of listing. + let mut names = (0..360) + .map(|index| format!("Alpha Placeholder {index:04}")) + .collect::>(); + names.extend((0..40).map(|index| format!("Beta Placeholder {index:04} Zetaomega"))); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + + let entities = (0..64) + .map(|index| SourceEntity::new(index, "Zetaomega").expect("valid")) + .collect::>(); + + super::CANDIDATE_SEARCHES.with(|count| count.set(0)); + let report = bound(&catalog, &entities); + let searches = super::CANDIDATE_SEARCHES.with(std::cell::Cell::get); + assert_eq!(report.totals().requested, 64); + assert_eq!( + searches, 1, + "a repeated name reaching a large family was searched {searches} times" + ); + + // The count the operator sees is the whole union, not the capped listing. + let unresolved = report.entities()[0].unresolved().expect("unbound"); + assert!( + unresolved.candidates.found() > MAX_CANDIDATES_PER_ENTITY, + "this fixture no longer exercises a result larger than the cap" + ); + assert!(unresolved.candidates.listed().len() <= MAX_CANDIDATES_PER_ENTITY); +} + +#[test] +fn an_ambiguity_lists_every_master_that_caused_it() { + // The reason is decided on the resolving fold and the candidates were + // gathered from the wide one, which is not always coarser: the wide fold + // replaces `-` but not `/`, so `AB/CD` and `AB CD` are one master to + // `verified_fold` and two to `master_identity_key`. Both tokens are below + // the shared-token threshold, so nothing else restored the space spelling, + // and the operator was shown an ambiguity with a complete-looking list of + // one — asked to choose between masters they could not see. + let catalog = ledgers(&["AB/CD", "AB CD", "Beta Supply"]); + let binding = bind_one_name(&catalog, "ab/cd"); + assert_eq!(reason(&binding), UnboundReason::NameAmbiguous); + assert_eq!(binding.bound_name(), None); + assert_eq!(candidate_names(&binding), ["AB CD", "AB/CD"]); + assert_eq!( + binding.unresolved().expect("unbound").candidates.found(), + 2, + "the count agreed with the short list rather than with the collision" + ); +} diff --git a/src-tauri/src/agent_failure_tests.rs b/src-tauri/src/agent_failure_tests.rs index 182e4cc3..ae933e08 100644 --- a/src-tauri/src/agent_failure_tests.rs +++ b/src-tauri/src/agent_failure_tests.rs @@ -134,3 +134,47 @@ async fn import_post_read_failures_retain_source_evidence_and_admission_errors_s assert!(!directory.path().join("imports").exists()); } } + +#[tokio::test] +async fn a_name_the_core_refuses_costs_no_live_read() { + // The tool schema and the binding core do not admit the same names: a + // control character satisfies the schema's length and count rules and is + // refused by `SourceEntity::new`. Parsing after the company verification + // and the catalogue read meant the refusal arrived only after two live + // round trips against the operator's books, and after evidence of them had + // been retained — for a request that could not have succeeded against any + // catalogue. + // + // The control is the endpoint itself: nothing is listening on it. A reach + // for Tally before the request is parsed cannot come back as + // `master_name_unsafe`, it comes back as a failure to connect — so the + // reason code is the proof that no read was attempted, not a restatement + // of the assertion. + let directory = tempfile::tempdir().unwrap(); + let server = Server::new(Settings { + endpoint: TallyEndpointConfig { + host: "127.0.0.1".into(), + // Port 1 is privileged and unbound here; any connection is refused. + port: 1, + }, + data_dir: directory.path().to_path_buf(), + max_rows: 10, + max_bytes: 200_000, + redaction: Redaction::None, + import_enabled: true, + writes_enabled: false, + }); + + let refused = server + .call_tool_response( + "validate_masters", + json!({"company_guid": CAPTURED_GUID, "ledgers": ["Cash\u{7}"]}), + ) + .await; + assert_eq!(refused.value["isError"], true); + let content: Value = + serde_json::from_str(refused.value["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(content["result"]["error"]["code"], "master_name_unsafe"); + // Nothing was read, so there is nothing to commit to. + assert_eq!(refused.value["structuredContent"]["evidence"]["bytes"], 0); +} diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index e0cef57d..5549eae4 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -345,24 +345,28 @@ impl Server { .collect::>>() .filter(|names| !names.is_empty()) .ok_or_else(|| "ledgers_required".to_string())?; + // Before either read. A name the core refuses is refused at any + // catalogue, so verifying the company and reading its ledgers first + // would spend two live round trips — and retain evidence of them — to + // reach a failure that was decidable from the request alone. + let entities = + source_entities(&ledgers.into_iter().map(str::to_string).collect::>()) + .map_err(ToolFailure::from)?; let (company, identity, identity_evidence) = self.verified_company(guid).await?; let (catalogue, evidence) = self .read_ledger_catalogue(&identity, &company.name) .await .map_err(|failure| failure.with_prior_evidence(identity_evidence.clone()))?; - let report = master_report( - &ledgers.into_iter().map(str::to_string).collect::>(), - &catalogue, - ) - // The catalogue read already succeeded, so its request/response - // commitments belong in the failure too; attaching identity evidence - // alone would omit a Tally read that actually happened. - .map_err(|code| { - ToolFailure::from(code).with_prior_evidence(combine_evidence( - identity_evidence.clone(), - evidence.clone(), - )) - })?; + let report = master_report(&entities, &catalogue) + // The catalogue read already succeeded, so its request/response + // commitments belong in the failure too; attaching identity evidence + // alone would omit a Tally read that actually happened. + .map_err(|code| { + ToolFailure::from(code).with_prior_evidence(combine_evidence( + identity_evidence.clone(), + evidence.clone(), + )) + })?; let hash = sha256_json(&catalogue); Ok(ToolOutcome { payload: json!({"company": company_json(&company, std::slice::from_ref(&company)), "result": {"masters": report, "catalogue_evidence_sha256": hash}}), @@ -1575,7 +1579,27 @@ fn masters_for_payload( payload: &ImportPayload, catalogue: &[String], ) -> Result, String> { - master_report(&requested_ledger_names(payload), catalogue) + master_report( + &source_entities(&requested_ledger_names(payload))?, + catalogue, + ) +} + +/// Parses requested names into source entities, which is where the core's own +/// bounds are enforced — a control character, or more identifiers than one name +/// may carry. +/// +/// Kept separate from `master_report` so a caller can run it **before** it +/// reads Tally. A request the core will refuse cannot succeed at any catalogue, +/// so spending a live read and collecting evidence of it first buys nothing and +/// costs an external round trip against the operator's books. +fn source_entities(requested: &[String]) -> Result, String> { + requested + .iter() + .enumerate() + .map(|(position, name)| SourceEntity::new(position, name)) + .collect::, _>>() + .map_err(|error| error.safe_reason_code().to_string()) } fn requested_ledger_names(payload: &ImportPayload) -> Vec { @@ -1600,16 +1624,10 @@ const MAX_CANDIDATE_RESULT_BYTES: usize = 8_192; /// desktop preparation screen cannot drift apart; see /// `docs/adr/0016-master-binding-authority.md`. This function only renders the /// report, and it never promotes a candidate into a spelling. -fn master_report(requested: &[String], catalogue: &[String]) -> Result, String> { +fn master_report(entities: &[SourceEntity], catalogue: &[String]) -> Result, String> { let catalog = MasterCatalog::new(MasterClass::Ledger, catalogue) .map_err(|error| error.safe_reason_code().to_string())?; - let entities = requested - .iter() - .enumerate() - .map(|(position, name)| SourceEntity::new(position, name)) - .collect::, _>>() - .map_err(|error| error.safe_reason_code().to_string())?; - let report = master_binding::bind(&catalog, &entities) + let report = master_binding::bind(&catalog, entities) .map_err(|error| error.safe_reason_code().to_string())?; Ok(report.entities().iter().map(master_match_json).collect()) } diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index 00052ca9..c3f45eaf 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -1508,9 +1508,12 @@ fn one_master_match(wanted: &str, catalogue: &[&str]) -> Value { .iter() .map(|name| (*name).to_string()) .collect::>(); - master_report(&[wanted.to_string()], &catalogue) - .expect("fabricated catalogue binds") - .remove(0) + master_report( + &source_entities(&[wanted.to_string()]).expect("fabricated name parses"), + &catalogue, + ) + .expect("fabricated catalogue binds") + .remove(0) } #[test] @@ -1559,7 +1562,7 @@ fn a_catalogue_that_was_never_read_refuses_instead_of_reporting_everything_missi // of the one failed engagement, so an empty catalogue must not look like // an answer. P5: nothing-found and request-failed stay distinguishable. assert_eq!( - master_report(&["Bank".to_string()], &[]), + master_report(&source_entities(&["Bank".to_string()]).expect("valid"), &[]), Err("master_catalog_empty".to_string()) ); } From 1046b52b5c406b3bd76d9641bdc0756a4c109067 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 16:53:02 +0530 Subject: [PATCH 46/75] fix: label conservative candidate counts --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 12 +++--- .../source-draft-capture-bindings.json | 3 ++ scripts/source-draft-screen.test.tsx | 20 +++++++-- .../bridge-tally-core/src/master_binding.rs | 19 +++++++-- .../src/master_binding_tests.rs | 41 +++++++++++++++++++ src-tauri/src/agent_import.rs | 3 ++ src-tauri/src/agent_import_tests.rs | 6 +++ src-tauri/src/source_draft/catalog.rs | 8 ++++ src/SourceDraftScreen.tsx | 9 ++-- src/source-draft-types.ts | 2 + 11 files changed, 109 insertions(+), 16 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 29739644..9909ca0e 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "8bd7596b4058958153ae798561e282719a42b6e35f3859b78a70c8641f62001e", + "compatibility_surface_sha256": "6a41feb32756d361ce83e19e0f8fb6402f6b86dd88006f4ca8fb60f554728640", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 8dbb71bf..5a5be2ff 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "d548eacca37ea587a3fb9f8d9f0bc8535c92134eef4886d881830e3f028c18e8" + "sha256": "774b41d6606482b9826605701e6146ea46b9b463aa6d1cb43f5a053f55ac569d" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -331,7 +331,7 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "c186d5d8618ce1b92ff02cf4451abf5e76eb435647e3c9ecae6e5aaea6210ba5" + "sha256": "aaed843e02cb3410b9aca9ab5bae46ea15b0d7bc7866f37f5c45edfaa1b8f117" }, { "path": "src-tauri/src/agent_ledgers.rs", @@ -583,7 +583,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "5cd0273bd2d7c399af0bef0c6a5a8c3098fd82404d8a31c8a6e2fd1c7b67c60d" + "sha256": "c87839f0c69facc514280cc42d42d8287fcfc3200275be10f132eeeff717e493" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -723,7 +723,7 @@ }, { "path": "src/SourceDraftScreen.tsx", - "sha256": "f36b299f87d1b47c17fe18af6bf5547884d3257e4db23c29c39913d31908a2be" + "sha256": "6a324579117c59a335e28f0df1d003d2032692682283db63a140dcaf5479e834" }, { "path": "src/TallyReadinessFlow.tsx", @@ -779,7 +779,7 @@ }, { "path": "src/source-draft-types.ts", - "sha256": "a2e32dd9a01d7aa0f0fb6c309bb648e6954068d926f5533f56f71bbffa7c2a86" + "sha256": "1254cc0b04ce6fc1032d6f01782f5cd05d8e8f523e8c36f65e4ab3fc46535a1f" }, { "path": "src/source-draft.css", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "8bd7596b4058958153ae798561e282719a42b6e35f3859b78a70c8641f62001e" + "manifest_sha256": "6a41feb32756d361ce83e19e0f8fb6402f6b86dd88006f4ca8fb60f554728640" } \ No newline at end of file diff --git a/scripts/fixtures/source-draft-capture-bindings.json b/scripts/fixtures/source-draft-capture-bindings.json index bc4e2230..6c56c3c5 100644 --- a/scripts/fixtures/source-draft-capture-bindings.json +++ b/scripts/fixtures/source-draft-capture-bindings.json @@ -4,6 +4,7 @@ "bound_basis": "exact_name", "bound_target": "Cash", "candidate_count": 0, + "candidate_count_is_lower_bound": false, "candidate_listing": "none", "candidates": [], "entry_position": 1, @@ -14,6 +15,7 @@ "bound_basis": "normalized_name", "bound_target": "WR2 Sales", "candidate_count": 0, + "candidate_count_is_lower_bound": false, "candidate_listing": "none", "candidates": [], "entry_position": 2, @@ -24,6 +26,7 @@ "bound_basis": null, "bound_target": null, "candidate_count": 1, + "candidate_count_is_lower_bound": false, "candidate_listing": "listed", "candidates": [ "Profit & Loss A/c" diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index 1b766bb5..89af639c 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -278,6 +278,7 @@ test("groups and lists a catalogue of realistic size without losing the narrowin unbound_reason: null, candidates: [], candidate_count: 0, + candidate_count_is_lower_bound: false, candidate_listing: "listed", }], }; @@ -317,6 +318,7 @@ test("says so when the operator chooses a different ledger from the one binding unbound_reason: null, candidates: [], candidate_count: 0, + candidate_count_is_lower_bound: false, candidate_listing: "none", }], }; @@ -359,6 +361,7 @@ test("says nothing extra when the operator chooses the ledger binding matched", unbound_reason: null, candidates: [], candidate_count: 0, + candidate_count_is_lower_bound: false, candidate_listing: "none", }], }; @@ -409,6 +412,7 @@ test("keeps the binding result visible beside a saved target nobody has re-read" unbound_reason: "master_binding_near_miss", candidates: ["Existing target"], candidate_count: 1, + candidate_count_is_lower_bound: false, candidate_listing: "listed", }], }; @@ -499,6 +503,7 @@ test("lists the bound ledger first without selecting it, and keeps the whole cat unbound_reason: null, candidates: [], candidate_count: 0, + candidate_count_is_lower_bound: false, candidate_listing: "listed", }], }; @@ -543,6 +548,7 @@ test("names the refusal when the name and the identifier point at different ledg unbound_reason: "master_binding_identifier_name_conflict", candidates: ["Alpha placeholder", "Gamma placeholder"], candidate_count: 2, + candidate_count_is_lower_bound: false, candidate_listing: "listed", }], }; @@ -576,6 +582,7 @@ test("distinguishes the two other refusals that are not weak matches", async () unbound_reason: reason, candidates: ["Alpha placeholder", "Gamma placeholder"], candidate_count: 2, + candidate_count_is_lower_bound: false, candidate_listing: "listed", }], }); @@ -647,6 +654,7 @@ test("a refusal reason survives the candidate listing being dropped", async () = unbound_reason: "master_binding_identifier_name_conflict", candidates: [], candidate_count: 6, + candidate_count_is_lower_bound: true, candidate_listing: "truncated", }], }); @@ -677,6 +685,7 @@ test("choosing a target stops the screen saying nothing was chosen, without hidi unbound_reason: "master_binding_identifier_name_conflict", candidates: ["Alpha placeholder", "Gamma placeholder"], candidate_count: 2, + candidate_count_is_lower_bound: false, candidate_listing: "listed", }], }; @@ -725,6 +734,7 @@ test("an empty list because the report ran out of room is not a family the name unbound_reason: "master_binding_near_miss", candidates: [], candidate_count: 7, + candidate_count_is_lower_bound: true, candidate_listing: "truncated", }], }; @@ -752,6 +762,7 @@ test("lists candidates first for a near miss and states that nothing was chosen" unbound_reason: "master_binding_near_miss", candidates: ["Alpha placeholder", "Gamma placeholder"], candidate_count: 2, + candidate_count_is_lower_bound: false, candidate_listing: "listed", }], }; @@ -784,6 +795,7 @@ test("reports a truncated candidate list truthfully and falls back to the flat c unbound_reason: "master_binding_near_miss", candidates: ["Alpha placeholder"], candidate_count: 40, + candidate_count_is_lower_bound: true, candidate_listing: "truncated", }], }; @@ -793,7 +805,7 @@ test("reports a truncated candidate list truthfully and falls back to the flat c const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); await act(async () => button(host, "Choose source XML").click()); await act(async () => button(host, "Load existing ledgers").click()); - expect(host.textContent).toContain("1 of 40 possible ledger is listed first"); + expect(host.textContent).toContain("1 of at least 40 possible ledger is listed first"); root.unmount(); }); @@ -815,6 +827,7 @@ test("a family withheld under a different reason is not reported as a full repor unbound_reason: "master_binding_identifier_conflict", candidates: [], candidate_count: 30, + candidate_count_is_lower_bound: true, candidate_listing: "withheld", }], }; @@ -825,7 +838,7 @@ test("a family withheld under a different reason is not reported as a full repor await act(async () => button(host, "Choose source XML").click()); await act(async () => button(host, "Load existing ledgers").click()); - expect(host.textContent).toContain("matches 30 existing ledgers and tells them apart from none of them, so none is listed"); + expect(host.textContent).toContain("matches at least 30 existing ledgers and tells them apart from none of them, so none is listed"); expect(host.textContent).not.toContain("ran out of room"); root.unmount(); }); @@ -846,6 +859,7 @@ test("a source line that separates no ledger says so instead of counting nothing unbound_reason: "master_binding_no_discriminating_candidate", candidates: [], candidate_count: 120, + candidate_count_is_lower_bound: true, candidate_listing: "withheld", }], }; @@ -856,7 +870,7 @@ test("a source line that separates no ledger says so instead of counting nothing await act(async () => button(host, "Choose source XML").click()); await act(async () => button(host, "Load existing ledgers").click()); - expect(host.textContent).toContain("matches 120 existing ledgers and tells them apart from none of them, so none is listed"); + expect(host.textContent).toContain("matches at least 120 existing ledgers and tells them apart from none of them, so none is listed"); expect(host.textContent).not.toContain("0 possible"); expect(host.textContent).not.toContain("listed first;"); const target = host.querySelector("#source-draft-1-entry-0-ledger")!; diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 601732c5..aceaf0a6 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -51,8 +51,9 @@ pub const MAX_CANDIDATES_PER_ENTITY: usize = 25; /// can carry tens of thousands of entries that each list 25 long names, and the /// clones exist the moment the report is built. A consumer capping its own copy /// afterwards bounds only the second copy. This is spent in entity order; -/// entities past it report their true `candidate_count` with no candidates -/// listed and truncation flagged. +/// entities past it report their `candidate_count` with no candidates listed +/// and truncation flagged. Consumers must inspect +/// `Candidates::count_is_lower_bound` before presenting that count as exact. pub const MAX_REPORT_CANDIDATE_BYTES: usize = 256 * 1024; /// Most identifiers one name may carry. Exceeding it is refused, never /// truncated. @@ -347,7 +348,9 @@ impl Candidates { } } - /// Masters found before any truncation or withholding. + /// Masters found before any truncation or withholding. The value is a + /// lower bound when the listing is incomplete; use + /// [`Self::count_is_lower_bound`] before presenting it as exact. pub fn found(&self) -> usize { match self { Self::None => 0, @@ -356,6 +359,16 @@ impl Candidates { } } + /// Whether `found()` is conservative because the report withheld or + /// truncated part of the evidence. Large identifier families are not + /// expanded, so overlapping families cannot be distinguished from one + /// another without materializing them. Keeping this fact beside the count + /// prevents a projection from turning a sound lower bound into a false + /// exact total. + pub fn count_is_lower_bound(&self) -> bool { + self.is_incomplete() + } + /// Whether masters exist that are not in `listed()`. The predicate a /// consumer needs before it may report "nothing like this is present": /// true here means the absence of a listing is not the absence of a master. diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 5fe30814..8915350b 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -1610,6 +1610,14 @@ fn only_an_incomplete_listing_may_withhold_an_absence() { found: 9 } .is_incomplete()); + assert!(!Candidates::None.count_is_lower_bound()); + assert!(!Candidates::Listed { listed: Vec::new() }.count_is_lower_bound()); + assert!(Candidates::Withheld { found: 30 }.count_is_lower_bound()); + assert!(Candidates::Truncated { + listed: Vec::new(), + found: 9 + } + .count_is_lower_bound()); // `found` is the total, never the listed length, wherever it is known. assert_eq!(Candidates::Withheld { found: 30 }.found(), 30); assert!(Candidates::Withheld { found: 30 }.listed().is_empty()); @@ -2427,6 +2435,39 @@ fn a_withheld_family_counts_the_masters_the_other_identifier_listed_too() { family + 1 > MAX_CANDIDATES_PER_ENTITY, "this fixture no longer exercises the skip" ); + assert!( + unresolved.candidates.count_is_lower_bound(), + "the skipped identifier family makes this count conservative" + ); +} + +#[test] +fn disjoint_withheld_identifier_families_are_marked_as_a_lower_bound() { + let mut names = (0..MAX_CANDIDATES_PER_ENTITY + 5) + .map(|index| format!("Alpha Party {index:03} (5550007777)")) + .collect::>(); + names.extend( + (0..MAX_CANDIDATES_PER_ENTITY + 5) + .map(|index| format!("Beta Party {index:03} (5550008888)")), + ); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let entity = + SourceEntity::with_identifier_hints(0, "Unrelated Source", ["5550007777", "5550008888"]) + .expect("valid"); + + let binding = bound(&catalog, &[entity]) + .entities() + .first() + .cloned() + .expect("one entity in, one binding out"); + let unresolved = binding.unresolved().expect("identifier conflict"); + assert_eq!(unresolved.candidates.listing(), "withheld"); + assert_eq!( + unresolved.candidates.found(), + MAX_CANDIDATES_PER_ENTITY + 5, + "the conservative count remains the larger known family" + ); + assert!(unresolved.candidates.count_is_lower_bound()); } #[test] diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index 5549eae4..20403ac9 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -1693,6 +1693,9 @@ fn master_match_json(binding: &EntityBinding) -> Value { "reason": unresolved.reason.safe_reason_code(), "listing": listing, "candidate_count": found, + "candidate_count_is_lower_bound": unresolved + .candidates + .count_is_lower_bound(), "candidates_truncated": listing != "listed" && listing != "none", "candidates": candidates, "unresolved_identity": unresolved diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index c3f45eaf..a0a6afc9 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -1532,6 +1532,7 @@ fn master_match_bounds_suggestions_before_copying_names_and_preserves_ambiguity( "master_binding_no_discriminating_candidate" ); assert_eq!(matched["candidate_count"], 100); + assert_eq!(matched["candidate_count_is_lower_bound"], true); assert_eq!(matched["candidates_truncated"], true); assert!(matched["candidates"].as_array().unwrap().is_empty()); // A family inside the bound is still listed in full. @@ -1551,6 +1552,10 @@ fn master_match_bounds_suggestions_before_copying_names_and_preserves_ambiguity( let limited = one_master_match("Large", &[huge.as_str()]); assert_eq!(limited["match_state"], "near_miss"); assert_eq!(limited["candidate_count"], 1); + // The agent's own byte cap can shorten a complete core listing, while + // the core count remains exact; this field describes count uncertainty, + // not whether this consumer copied every candidate name. + assert_eq!(limited["candidate_count_is_lower_bound"], false); assert_eq!(limited["candidates_truncated"], true); assert!(limited["candidates"].as_array().unwrap().is_empty()); assert!(!limited.to_string().contains(&huge)); @@ -1593,6 +1598,7 @@ fn a_near_miss_never_names_a_live_spelling_and_retains_its_identity() { assert_eq!(matched["reason"], "master_binding_identifier_conflict"); assert!(matched.get("exact_live_spelling").is_none()); assert_eq!(matched["candidate_count"], 2); + assert_eq!(matched["candidate_count_is_lower_bound"], false); assert_eq!( matched["unresolved_identity"][0]["value"][super::super::PARTY_NAME_MARKER], "5550000001" diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index 9a9d9521..af83d0b1 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -82,6 +82,10 @@ pub(crate) struct SourceDraftCatalogBinding { pub(crate) unbound_reason: Option<&'static str>, pub(crate) candidates: Vec, pub(crate) candidate_count: usize, + /// `true` when the core could only establish a lower bound because part + /// of the candidate evidence was withheld or truncated. The number must + /// then be rendered as "at least N" rather than as an exact total. + pub(crate) candidate_count_is_lower_bound: bool, /// Which of the four candidate states this is, in the word the core type /// already tags its serialized form with. Carried rather than inferred: an /// empty listing beside a nonzero count is two different results — a family @@ -192,6 +196,7 @@ fn source_entry_bindings( unbound_reason: None, candidates: Vec::new(), candidate_count: 0, + candidate_count_is_lower_bound: false, candidate_listing: Candidates::None.listing(), }, BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved) => { @@ -214,6 +219,9 @@ fn source_entry_bindings( .map(|candidate| candidate.catalog_name.clone()) .collect(), candidate_count: unresolved.candidates.found(), + candidate_count_is_lower_bound: unresolved + .candidates + .count_is_lower_bound(), } } }, diff --git a/src/SourceDraftScreen.tsx b/src/SourceDraftScreen.tsx index b7ac558d..72dcfde5 100644 --- a/src/SourceDraftScreen.tsx +++ b/src/SourceDraftScreen.tsx @@ -143,6 +143,9 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: if (binding.candidate_count === 0) { return `No existing ledger matched this source line. All ${total} are listed.`; } + const count = binding.candidate_count_is_lower_bound + ? `at least ${binding.candidate_count}` + : `${binding.candidate_count}`; const shown = binding.candidates.length; if (shown === 0) { // Two different facts arrive here with an empty list and a nonzero count, @@ -161,14 +164,14 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: // sentence and told the operator the report had run out of room when it // had not. if (binding.candidate_listing === "withheld") { - return `This source line matches ${binding.candidate_count} existing ledgers and tells them apart from none of them, so none is listed. Use a fuller source name, or choose from the full list of ${total}.`; + return `This source line matches ${count} existing ledgers and tells them apart from none of them, so none is listed. Use a fuller source name, or choose from the full list of ${total}.`; } // Why it refused survives the listing being dropped. Returning only the // budget sentence here re-hid the strong disagreement that the branch below // had just been fixed to show — the same defect, one branch over. - return `${catalogRefusalLead(binding.unbound_reason)} ${binding.candidate_count} existing ledgers are involved, but this report ran out of room to list them. Choose from the full list of ${total}.`; + return `${catalogRefusalLead(binding.unbound_reason)} ${count} existing ledgers are involved, but this report ran out of room to list them. Choose from the full list of ${total}.`; } - const listed = binding.candidate_listing === "truncated" ? `${shown} of ${binding.candidate_count}` : `${shown}`; + const listed = binding.candidate_listing === "truncated" ? `${shown} of ${count}` : `${shown}`; const lead = catalogRefusalLead(binding.unbound_reason); return `${lead} Nothing is chosen; ${listed} possible ${shown === 1 ? "ledger is" : "ledgers are"} listed first, and the full list of ${total} follows.`; } diff --git a/src/source-draft-types.ts b/src/source-draft-types.ts index b75b07ac..ca274e05 100644 --- a/src/source-draft-types.ts +++ b/src/source-draft-types.ts @@ -77,6 +77,8 @@ export type SourceDraftCatalogBinding = { unbound_reason: string | null; candidates: string[]; candidate_count: number; + /** Whether candidate_count is a conservative lower bound. */ + candidate_count_is_lower_bound: boolean; /** "none" | "listed" | "truncated" | "withheld" — the core's own word for * this state, carried rather than inferred: an empty list beside a nonzero * count is a withheld family or an exhausted budget, and they differ. */ From 71ca8a56099f52041932e833893bbda53e2a3e2e Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 16:58:59 +0530 Subject: [PATCH 47/75] docs: specify candidate count precision for consumers --- docs/adr/0016-master-binding-authority.md | 16 +++++++++++----- docs/agent/README.md | 10 +++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 38dcd522..1a7feecd 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -297,9 +297,14 @@ it records what was observed). suppression is measured from the catalog rather than from a built-in word list, which keeps it free of language and domain assumptions. -Candidates are capped at `MAX_CANDIDATES_PER_ENTITY` (25) with the true -`candidate_count` and an explicit `candidates_truncated` flag retained, so a -truncated list is never mistaken for a short one. +Candidates are capped at `MAX_CANDIDATES_PER_ENTITY` (25). The core retains +`candidate_count`, listing state and `Candidates::count_is_lower_bound()`. +The MCP and desktop projections expose `candidate_count_is_lower_bound`: true +means the number is a conservative lower bound and must be shown as "at least N". +A withheld or core-truncated listing does not establish an exact union count. +A later consumer-only copy cap can shorten a complete listing while retaining +an exact count; `candidates_truncated` alone therefore does not describe count +precision. ### 4a. An empty candidate list is three different facts, and the producer says which @@ -309,11 +314,12 @@ things to anyone deciding what to do next: | `reason` | what empty means | | --- | --- | | `NoCandidate` | no master resembles this name at all | -| `NoDiscriminatingCandidate` | `candidate_count` masters resemble it and none is separable — **many exist**, none is worth showing | +| `NoDiscriminatingCandidate` | at least `candidate_count` masters resemble it when the count is a lower bound, and none is separable — **many exist**, none is worth showing | | any, with `candidates_truncated` | the list was cut, by the per-entity cap or by the report's aggregate byte budget | So `candidates.is_empty()` alone answers nothing. The disambiguators are -`reason`, `candidate_count` and `candidates_truncated`, and a consumer that +`reason`, `candidate_count`, `candidate_count_is_lower_bound` and +`candidates_truncated`, and a consumer that reads the empty vector as "nothing exists" is wrong in two cases out of three. This is stated here, in the producer's contract, rather than left to each diff --git a/docs/agent/README.md b/docs/agent/README.md index 4c0f6cf2..1f35a4e6 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -216,8 +216,10 @@ licence mode, or manually imported file, and only an unnumbered single-voucher `candidates`, each labelled with the rule that surfaced it. A single candidate is still not a decision. Where `reason` is `master_binding_no_discriminating_candidate`, the name reaches - `candidate_count` masters that it does not distinguish and none is listed; - use a more complete source name, or read the ledger list and choose. + `candidate_count` possible masters that it does not distinguish and none + is listed. When `candidate_count_is_lower_bound` is true, show this as + "at least N", never an exact total. Use a more complete source name, + or read the ledger list and choose. - `missing` — no live ledger matched. Bridge never creates masters. 3. Call `build_import_xml` with the payload. It checks exact decimal balance, company date extent, live masters, and local journal integrity, @@ -449,7 +451,9 @@ boundary. `changed_since` is unavailable; existing clients must stop calling it. `validate_masters` accepts 1–100 nonblank names, each at most 1024 characters. Near-miss suggestions are limited to 25 names and 8192 UTF-8 bytes per requested -name; `candidate_count` and `candidates_truncated` preserve ambiguity. Import +name; `candidate_count`, `candidate_count_is_lower_bound` and +`candidates_truncated` preserve ambiguity and count precision. A true lower-bound +flag means "at least N" even when no candidates are listed. Import planning allows 1000 vouchers but at most 100 distinct ledger names per batch. Repeated uses of a ledger do not consume additional distinct-name slots. Voucher-type and ledger selectors share the 1024-character bound; ledger From e5afea8c3617471114445ad5357f50f070425486 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 17:04:10 +0530 Subject: [PATCH 48/75] fix: memoize repeated derived candidate keys --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 108 ++++++++++-------- .../src/master_binding_tests.rs | 42 ++++++- 4 files changed, 103 insertions(+), 53 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 6231c0ad..e920bb74 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "8f8019fc85e37f3dcf44912a6c4033ee917fa6edee33d37015016083232a0135", + "compatibility_surface_sha256": "3aaecf0ca6770812530274cd455c08a69c4e414b6ab242c7869d9a548cbf69fd", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 1d302635..590ff617 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "774b41d6606482b9826605701e6146ea46b9b463aa6d1cb43f5a053f55ac569d" + "sha256": "855b08a54fee4ac9e6c0f7a5f9a0b78ecf84e0cb254d9e76da6c16266355d9d1" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "8f8019fc85e37f3dcf44912a6c4033ee917fa6edee33d37015016083232a0135" + "manifest_sha256": "3aaecf0ca6770812530274cd455c08a69c4e414b6ab242c7869d9a548cbf69fd" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index aceaf0a6..03b7bb88 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -12,6 +12,7 @@ //! path that owns identity. use std::collections::{BTreeMap, BTreeSet}; +use std::hash::{Hash, Hasher}; use serde::{Deserialize, Serialize}; use unicode_normalization::UnicodeNormalization; @@ -930,43 +931,18 @@ pub fn bind( } } let mut budget = MAX_REPORT_CANDIDATE_BYTES; - // Which source keys actually repeat, decided before any of them is bound. - // - // A first-come cap made the memo's protection depend on **source order**: + // Which *memo keys* actually repeat, decided before any candidate search + // runs. A first-come cap made the memo's protection depend on source order: // 1,024 distinct cheap misses at the head of a draft filled it, and the - // repeated expensive key behind them was then never cached — the stall the - // memo exists to prevent, reachable by reordering the same rows. Counting - // first removes the ordering entirely, and caches only what a second row - // will ask for again. - // - // Counted on the **whole** determinant of a memo entry, not on the name - // alone. The memo is keyed by the source key *and* the masters the - // identifiers reached, so counting `key` by itself called every hint - // variant of one name repeated: 1,024 singleton variants of `Acme Branch` - // then filled the memo with entries nothing would ask for twice, and a key - // that genuinely repeated behind them could no longer be inserted — the - // stall the memo exists to prevent, reached by a different door than the - // source-order one. - // - // The identifiers are the source-side determinant of those masters: the - // same key with the same identifiers always produces the same memo key - // against a given catalog. The converse does not hold — two different - // identifier sets can reach the same masters — so this counts no pair as - // repeated that is not, and at worst declines to cache one that is. + // repeated expensive key behind them was then never cached. // - // This borrows the keys rather than cloning them, so it costs no more than - // the entity list it is counting. - let mut repeats: BTreeMap<(&str, &[Identifier]), usize> = BTreeMap::new(); - for entity in entities { - *repeats - .entry((entity.key.as_str(), entity.identifiers.as_slice())) - .or_insert(0) += 1; - } - let repeated = repeats - .into_iter() - .filter(|(_, count)| *count > 1) - .map(|(key, _)| key) - .collect::>(); + // The key is the source fold plus the masters its identifiers reached, not + // the raw identifier list. Different unmatched hints all reach the same + // empty set, so proxy-counting the raw lists re-ran their one expensive + // candidate search once per row. Fingerprints keep this prepass bounded by + // `MAX_SOURCE_ENTITIES` without holding another owned key/set per entity; + // the memo itself remains capped and checks the full key before reuse. + let repeated = repeated_candidate_memo_fingerprints(catalog, entities); let mut memo = SearchMemo { seen: CandidateMemo::new(), @@ -995,7 +971,8 @@ pub fn bind( /// candidate list it holds — a draft of 40,000 *distinct* names would trade the /// stall for the memory the aggregate bounds elsewhere exist to prevent. The /// repeated-name case, which is the one that stalls, needs very few entries. -type CandidateMemo = BTreeMap<(String, BTreeSet), (Vec<(usize, CandidateRule)>, usize)>; +type CandidateMemoKey = (String, BTreeSet); +type CandidateMemo = BTreeMap, usize)>; /// One run's search scratch: what has already been computed, and which source /// keys a second row will ask for again. Carried together because they are one @@ -1016,10 +993,12 @@ struct IdentifierEvidence<'a> { withheld_holders: usize, } -struct SearchMemo<'a> { +struct SearchMemo { seen: CandidateMemo, - /// The (source key, identifiers) pairs a second row will ask for again. - repeated: BTreeSet<(&'a str, &'a [Identifier])>, + /// Fingerprints of the full memo keys a later entity will ask for again. + /// A collision can retain one otherwise-singleton result, but can never + /// reuse it: `seen` remains keyed by the complete value. + repeated: BTreeSet, } const MAX_CANDIDATE_MEMO_ENTRIES: usize = 1_024; @@ -1028,7 +1007,7 @@ fn bind_one( catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize, - memo: &mut SearchMemo<'_>, + memo: &mut SearchMemo, ) -> EntityBinding { let exact = catalog.by_name.get(&entity.name).copied(); @@ -1268,7 +1247,7 @@ fn unresolved_status( reason: UnboundReason, evidence: IdentifierEvidence<'_>, budget: &mut usize, - memo: &mut SearchMemo<'_>, + memo: &mut SearchMemo, ) -> BindingStatus { let IdentifierEvidence { exact, @@ -1360,12 +1339,13 @@ fn remembered_candidates( catalog: &MasterCatalog, entity: &SourceEntity, identifier_matches: &BTreeSet, - memo: &mut SearchMemo<'_>, + memo: &mut SearchMemo, ) -> (Vec<(usize, CandidateRule)>, usize) { let key = (entity.key.clone(), identifier_matches.clone()); if let Some(remembered) = memo.seen.get(&key) { return remembered.clone(); } + let fingerprint = candidate_memo_fingerprint(&key.0, &key.1); let computed = collect_candidates(catalog, entity, identifier_matches); // Entry *count* alone does not bound a memo whose keys and values are // themselves collections, so the key is still size-tested. The **value** is @@ -1376,16 +1356,52 @@ fn remembered_candidates( // search the memo exists for, and a name reaching twenty thousand masters // through shared tokens re-ran it once per row. debug_assert!(computed.0.len() <= MAX_CANDIDATES_PER_ENTITY); - let worth_holding = memo - .repeated - .contains(&(entity.key.as_str(), entity.identifiers.as_slice())) - && key.1.len() <= MAX_CANDIDATES_PER_ENTITY; + let worth_holding = + memo.repeated.contains(&fingerprint) && key.1.len() <= MAX_CANDIDATES_PER_ENTITY; if worth_holding && memo.seen.len() < MAX_CANDIDATE_MEMO_ENTRIES { memo.seen.insert(key, computed.clone()); } computed } +/// Counts derived memo keys before candidate collection so a repeated key is +/// retained from its first computation, independent of source order. The +/// identifier pass here mirrors the memo key's existing definition: holders +/// too large to materialize do not enter `identifier_matches` there either. +fn repeated_candidate_memo_fingerprints( + catalog: &MasterCatalog, + entities: &[SourceEntity], +) -> BTreeSet { + let mut occurrences = BTreeMap::::new(); + for entity in entities { + let fingerprint = candidate_memo_fingerprint_for_entity(catalog, entity); + *occurrences.entry(fingerprint).or_insert(0) += 1; + } + occurrences + .into_iter() + .filter_map(|(fingerprint, count)| (count > 1).then_some(fingerprint)) + .collect() +} + +fn candidate_memo_fingerprint_for_entity(catalog: &MasterCatalog, entity: &SourceEntity) -> u64 { + let mut identifier_matches = BTreeSet::new(); + for identifier in &entity.identifiers { + if let Some(holders) = catalog.by_identifier.get(identifier) { + if holders.len() <= MAX_CANDIDATES_PER_ENTITY { + identifier_matches.extend(holders.iter().copied()); + } + } + } + candidate_memo_fingerprint(&entity.key, &identifier_matches) +} + +fn candidate_memo_fingerprint(key: &str, identifier_matches: &BTreeSet) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + key.hash(&mut hasher); + identifier_matches.hash(&mut hasher); + hasher.finish() +} + // Counts holder sets actually materialized, for the same reason as the search // counter below: the *outcome* of expanding a family and of refusing to is // identical — a conflict either way — so a test asserting the outcome cannot diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 8915350b..340493bd 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -2470,6 +2470,35 @@ fn disjoint_withheld_identifier_families_are_marked_as_a_lower_bound() { assert!(unresolved.candidates.count_is_lower_bound()); } +#[test] +fn unmatched_hint_variants_share_one_memo_key_and_compute_once() { + let names = (0..60) + .map(|index| format!("Acme Branch {index:05}")) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + // Every raw hint differs, but none reaches a catalog identifier. The memo + // key is therefore the same `(acme branch, {})` for every entity. + let entities = (0..MAX_CANDIDATE_MEMO_ENTRIES + 4) + .map(|index| { + SourceEntity::with_identifier_hints( + index, + "Acme Branch", + [format!("5550{index:06}").as_str()], + ) + .expect("valid") + }) + .collect::>(); + + super::CANDIDATE_SEARCHES.with(|count| count.set(0)); + let report = bound(&catalog, &entities); + assert_eq!(report.totals().requested, MAX_CANDIDATE_MEMO_ENTRIES + 4); + assert_eq!( + super::CANDIDATE_SEARCHES.with(std::cell::Cell::get), + 1, + "different unmatched hints must share their one derived memo key" + ); +} + #[test] fn hint_variants_of_one_name_do_not_crowd_out_a_key_that_repeats() { // The memo is keyed by the source key *and* the masters the identifiers @@ -2478,12 +2507,17 @@ fn hint_variants_of_one_name_do_not_crowd_out_a_key_that_repeats() { // nothing asks for twice, and the pair that genuinely repeated behind them // could no longer be inserted — the same stall as the source-order defect, // through a different door. - let names = (0..60) - .map(|index| format!("Acme Branch {index:05}")) - .collect::>(); + let mut names = vec!["Acme Branch".to_string()]; + names.extend( + (0..MAX_CANDIDATE_MEMO_ENTRIES) + .map(|index| format!("Hint Target {index:05} (5550{index:06})")), + ); + names.push("Repeated Hint Target (5559999999)".to_string()); let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); - // Distinct hints, one name: same key, different memo key, each asked once. + // Distinct hints, one name: each reaches a different master, so each has a + // different memo key and is asked only once. The exact source name keeps + // all of them on the unresolved candidate path. let mut entities = (0..MAX_CANDIDATE_MEMO_ENTRIES) .map(|index| { SourceEntity::with_identifier_hints( From e1e22991cbc1069034a09564f66309a2032e420f Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 17:16:16 +0530 Subject: [PATCH 49/75] perf: bisect large identifier holder membership --- .../crates/bridge-tally-core/src/master_binding.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 03b7bb88..85f4d7b2 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1052,7 +1052,15 @@ fn bind_one( // materialization. Dropping it made the invariant // size-dependent: a hint pointing entirely elsewhere let the // exact name bind, but only once the family grew past the cap. - if exact.is_some_and(|index| !holders.contains(&index)) { + // `by_identifier` is filled by pushing entry indices from the + // ascending `entries.iter().enumerate()` walk in `new`, so + // this exact holder vector is sorted by construction. Keep + // the assertion beside the lookup that relies on it. + debug_assert!( + holders.is_sorted(), + "identifier holder lists are built in entry order" + ); + if exact.is_some_and(|index| holders.binary_search(&index).is_err()) { large_holder_points_elsewhere = true; } continue; From 695783eba29cbf9e1790d13d9f7c62b864e39503 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 17:21:32 +0530 Subject: [PATCH 50/75] chore: reseal catalogue membership correction --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index e920bb74..4e64427a 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "3aaecf0ca6770812530274cd455c08a69c4e414b6ab242c7869d9a548cbf69fd", + "compatibility_surface_sha256": "9ee32df518a1bde53d2fe6a2f6a298171a28fd40977c9d4984a679d9d0f60f44", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 590ff617..cca3a8fc 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "855b08a54fee4ac9e6c0f7a5f9a0b78ecf84e0cb254d9e76da6c16266355d9d1" + "sha256": "1e6498291f83cf18c7b3f4f2d7dac51172312577027d6e676c35b47c1da0023b" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "3aaecf0ca6770812530274cd455c08a69c4e414b6ab242c7869d9a548cbf69fd" + "manifest_sha256": "9ee32df518a1bde53d2fe6a2f6a298171a28fd40977c9d4984a679d9d0f60f44" } \ No newline at end of file From 7d252107b5005254436aa327600604b7b0b9b1e3 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 17:28:01 +0530 Subject: [PATCH 51/75] docs(agent): describe candidate count precision --- src-tauri/src/agent_catalog.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index b3dffa5c..c1e158bd 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -148,7 +148,7 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: json!({"type":"object", "additionalProperties":false}), ), "validate_masters" => ( - "Bind 1–100 nonblank ledger names (at most 1024 characters each) against the live catalogue. An identifier embedded in a master name is matched before the name itself. `match_state` is exact, normalized, identifier, near_miss or missing; only `exact` is admitted by build_import_xml, and a bound row alone carries `exact_live_spelling`. A near-miss is never resolved: it returns candidates with the rule that surfaced each, bounded to 25 names and 8192 UTF-8 bytes per requested name, with total count and truncation reported. There is no ranking and no score.", + "Bind 1–100 nonblank ledger names (at most 1024 characters each) against the live catalogue. An identifier embedded in a master name is matched before the name itself. `match_state` is exact, normalized, identifier, near_miss or missing; only `exact` is admitted by build_import_xml, and a bound row alone carries `exact_live_spelling`. A near-miss is never resolved: it returns candidates with the rule that surfaced each, bounded to 25 names and 8192 UTF-8 bytes per requested name, with candidate_count, candidate_count_is_lower_bound and truncation reported. When candidate_count_is_lower_bound is true, the count is a conservative lower bound and must be shown as at least that many candidates. There is no ranking and no score.", json!({"type":"object", "additionalProperties":false, "required":["company_guid","ledgers"], "properties":{"company_guid":{"type":"string"},"ledgers":{"type":"array","minItems":1,"maxItems":agent_import::MAX_MASTER_NAMES,"items":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"}}}}), ), "build_import_xml" => ( From 45e86331f932040762b392c75575ea375ac54e68 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 17:39:54 +0530 Subject: [PATCH 52/75] fix(ui): keep withheld identifier conflicts visible --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- scripts/source-draft-screen.test.tsx | 2 ++ src/SourceDraftScreen.tsx | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 4e64427a..4b06e3f4 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "9ee32df518a1bde53d2fe6a2f6a298171a28fd40977c9d4984a679d9d0f60f44", + "compatibility_surface_sha256": "72e04378abfb2b9ff678d87153793f14434caa4e69960fcc6a7bcf1363379ae1", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index cca3a8fc..9d4633e7 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -723,7 +723,7 @@ }, { "path": "src/SourceDraftScreen.tsx", - "sha256": "ea1b88fec5e0b8918a6ec31f8de75224d6e41723059a1b8585cbd3db3bf46d0e" + "sha256": "bf4bad949a956ddd780358fa52385f1db4dc1014bb489fdb0089e5c0fa59fcdd" }, { "path": "src/TallyReadinessFlow.tsx", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "9ee32df518a1bde53d2fe6a2f6a298171a28fd40977c9d4984a679d9d0f60f44" + "manifest_sha256": "72e04378abfb2b9ff678d87153793f14434caa4e69960fcc6a7bcf1363379ae1" } \ No newline at end of file diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index c52bd460..8689405c 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -842,6 +842,8 @@ test("a family withheld under a different reason is not reported as a full repor await act(async () => button(host, "Load existing ledgers").click()); expect(host.textContent).toContain("matches at least 30 existing ledgers and tells them apart from none of them, so none is listed"); + expect(host.textContent).toContain("The identifiers in this source line do not agree on one existing ledger"); + expect(host.textContent).toContain("either one of them appears in several, or they point at different ones"); expect(host.textContent).not.toContain("ran out of room"); root.unmount(); }); diff --git a/src/SourceDraftScreen.tsx b/src/SourceDraftScreen.tsx index 9b87ba63..5f5211ac 100644 --- a/src/SourceDraftScreen.tsx +++ b/src/SourceDraftScreen.tsx @@ -183,7 +183,7 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: // sentence and told the operator the report had run out of room when it // had not. if (binding.candidate_listing === "withheld") { - return `This source line matches ${count} existing ledgers and tells them apart from none of them, so none is listed. Use a fuller source name, or choose from the full list of ${total}.`; + return `${catalogRefusalLead(binding.unbound_reason)} This source line matches ${count} existing ledgers and tells them apart from none of them, so none is listed. Use a fuller source name, or choose from the full list of ${total}.`; } // Why it refused survives the listing being dropped. Returning only the // budget sentence here re-hid the strong disagreement that the branch below From 8803ef2c2c81ddad0509f695d60d34b6fdb65b42 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 18:49:32 +0530 Subject: [PATCH 53/75] fix(master-binding): separate count precision from listing completeness, and key the memo on both folds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The memo key named one fold while the search read two.** `collect_candidates` reads `entity.binding_key` as well as `entity.key` — I added that on #288 so the masters which *caused* a name ambiguity are the ones listed — and I did not extend the key that guards it. `AB-CD` and `AB–CD` share a wide key, because `comparison_key` maps every dash variant to `-`, and differ under the resolving fold, because §9.4d sent an en dash at a live master and Tally rejected it. The gateway's own refusal is what makes the keys diverge, and the memo crossed them: one spelling could be served the other's candidates. The key now carries both folds and the fingerprint hashes both. Only the key half is observable — a coarser fingerprint cannot serve a wrong result, it can only re-admit entries the pre-count exists to exclude, which needs a thousand colliding keys against nine dash variants — so the fingerprint is right by construction and its doc says so rather than a test implying otherwise. **Count precision and listing completeness are different questions.** `count_is_lower_bound()` derived the first from the second, so a prefix family of a hundred masters — unioned with the listed candidates *before* the decision not to show it, and therefore counted exactly — was reported to every consumer as "at least 100". Under-claiming is the safe direction and still a wrong statement about the book, and a hedge that fires when it need not trains an operator to discount it where it means something. It is a carried fact now, true in exactly one case: an identifier family was too large to expand, so the count is the larger of two possibly-overlapping sets rather than their union. Everything else computes a true union first. The change failed exactly one test, and it was the finding's own example: an agent assertion pinning `candidate_count_is_lower_bound: true` over a hundred-ledger prefix family. The over-hedge was not merely unnoticed; a test held it in place. **The ADR named a field this PR removes.** It pointed desktop consumers at `candidates_truncated`, which as a boolean was `is_incomplete()` and so could not separate a withheld family from an exhausted budget — the distinction its own table turns on. It now documents the listing state per boundary, and records that the agent surface keeps a field of that name meaning something narrower: its own rendering cap, which is why it can be true beside an exact count. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 21 ++- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 90 +++++++++-- .../src/master_binding_tests.rs | 146 +++++++++++++++++- src-tauri/src/agent_import_tests.rs | 9 +- 6 files changed, 241 insertions(+), 31 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 1a7feecd..9d2b4749 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -315,12 +315,25 @@ things to anyone deciding what to do next: | --- | --- | | `NoCandidate` | no master resembles this name at all | | `NoDiscriminatingCandidate` | at least `candidate_count` masters resemble it when the count is a lower bound, and none is separable — **many exist**, none is worth showing | -| any, with `candidates_truncated` | the list was cut, by the per-entity cap or by the report's aggregate byte budget | +| any, with an incomplete listing | the list was cut, by the per-entity cap or by the report's aggregate byte budget | So `candidates.is_empty()` alone answers nothing. The disambiguators are -`reason`, `candidate_count`, `candidate_count_is_lower_bound` and -`candidates_truncated`, and a consumer that -reads the empty vector as "nothing exists" is wrong in two cases out of three. +`reason`, `candidate_count`, `candidate_count_is_lower_bound` and the listing +state, and a consumer that reads the empty vector as "nothing exists" is wrong +in two cases out of three. + +**The listing state is named differently at each boundary, and a consumer must +use the one its own boundary carries.** The core's `Candidates` is a tagged +enum — `none`, `listed`, `truncated`, `withheld` — reachable in Rust through +`Candidates::listing()` and on the agent surface as the `listing` field. The +**desktop** DTO flattens it to `candidate_listing`, carrying the same four +words. `candidates_truncated` is **not** the desktop discriminator: the field +of that name on `SourceDraftCatalogBinding` was removed, because as a boolean +it was `is_incomplete()` and so could not separate a withheld family from an +exhausted budget — the distinction the two rows above turn on. The agent +surface keeps a field of that name, but it means something narrower there: that +*its own* 8 KiB rendering cap cut the list, which is why it can be true beside +an exact count. This is stated here, in the producer's contract, rather than left to each consumer to rediscover, because **it has already been got wrong twice by diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 4b06e3f4..a0260855 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "72e04378abfb2b9ff678d87153793f14434caa4e69960fcc6a7bcf1363379ae1", + "compatibility_surface_sha256": "0b3071613e1b380e28b4acc73a77d764db90c729cc60316eece14a672b099a69", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 9d4633e7..bab51ac7 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "1e6498291f83cf18c7b3f4f2d7dac51172312577027d6e676c35b47c1da0023b" + "sha256": "d40f9fac1cc0ea7dcad263d5e5c335651ad0d70cbe7dbfa138bd823a81c593cd" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "72e04378abfb2b9ff678d87153793f14434caa4e69960fcc6a7bcf1363379ae1" + "manifest_sha256": "0b3071613e1b380e28b4acc73a77d764db90c729cc60316eece14a672b099a69" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 85f4d7b2..fc1dcfde 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -315,11 +315,21 @@ pub enum Candidates { Truncated { listed: Vec, found: usize, + /// Whether `found` is a floor rather than a total. See + /// [`Candidates::count_is_lower_bound`]. + #[serde(default)] + count_is_lower_bound: bool, }, /// A family this name reaches and separates none of: counted, and /// deliberately not listed, because an arbitrary slice of it put the right /// master out of view about a third of the time against live books. - Withheld { found: usize }, + Withheld { + found: usize, + /// Whether `found` is a floor rather than a total. See + /// [`Candidates::count_is_lower_bound`]. + #[serde(default)] + count_is_lower_bound: bool, + }, } impl Candidates { @@ -356,18 +366,38 @@ impl Candidates { match self { Self::None => 0, Self::Listed { listed } => listed.len(), - Self::Truncated { found, .. } | Self::Withheld { found } => *found, + Self::Truncated { found, .. } | Self::Withheld { found, .. } => *found, } } - /// Whether `found()` is conservative because the report withheld or - /// truncated part of the evidence. Large identifier families are not - /// expanded, so overlapping families cannot be distinguished from one - /// another without materializing them. Keeping this fact beside the count - /// prevents a projection from turning a sound lower bound into a false - /// exact total. + /// Whether `found()` is a floor rather than a total. + /// + /// **Count precision and listing completeness are different questions**, and + /// deriving this from `is_incomplete()` conflated them. A prefix family of a + /// hundred masters is counted *exactly* — `collect_candidates` unions it + /// with the listed candidates before deciding not to show it — and every + /// consumer was told "at least 100" about a number that was 100. Under- + /// claiming is the safe direction, but it is still a wrong statement about + /// the book, and it trains an operator to discount a hedge that elsewhere + /// means something. + /// + /// The count is a floor in exactly one situation: an **identifier** family + /// was too large to expand. Those are not unioned with anything, so the + /// reported count is the larger of two possibly-overlapping sets rather + /// than their union — see issue #325. Every other path computes a true + /// union before it decides what to show. pub fn count_is_lower_bound(&self) -> bool { - self.is_incomplete() + match self { + Self::None | Self::Listed { .. } => false, + Self::Truncated { + count_is_lower_bound, + .. + } + | Self::Withheld { + count_is_lower_bound, + .. + } => *count_is_lower_bound, + } } /// Whether masters exist that are not in `listed()`. The predicate a @@ -971,7 +1001,14 @@ pub fn bind( /// candidate list it holds — a draft of 40,000 *distinct* names would trade the /// stall for the memory the aggregate bounds elsewhere exist to prevent. The /// repeated-name case, which is the one that stalls, needs very few entries. -type CandidateMemoKey = (String, BTreeSet); +/// Both folds, not just the wide one. `collect_candidates` reads +/// `entity.binding_key` as well as `entity.key` — it offers the resolving +/// fold's holders as candidates, so that the masters which *caused* a name +/// ambiguity are the ones listed — and a key that named only the wide fold +/// served one spelling's candidates to another. `AB-CD` and `AB\u{2013}CD` share a +/// wide key, because `comparison_key` maps every dash variant to `-`, and +/// differ under the resolving fold, because only ASCII `-` and `/` fold there. +type CandidateMemoKey = (String, String, BTreeSet); type CandidateMemo = BTreeMap, usize)>; /// One run's search scratch: what has already been computed, and which source @@ -1236,6 +1273,7 @@ fn bind_one( reason, candidates, masters_found.max(withheld_holders), + withheld_holders > 0, budget, ) } @@ -1279,6 +1317,7 @@ fn unresolved_status( reason, candidates, masters_found.max(withheld_holders), + withheld_holders > 0, budget, ) } @@ -1289,6 +1328,10 @@ fn unresolved_from( reason: UnboundReason, candidates: Vec<(usize, CandidateRule)>, masters_found: usize, + // True when `masters_found` came from a skipped identifier family, which is + // the one case the count cannot be a true union. See + // `Candidates::count_is_lower_bound`. + count_is_lower_bound: bool, budget: &mut usize, ) -> BindingStatus { let mut ordered = candidates; @@ -1299,6 +1342,7 @@ fn unresolved_from( if masters_found > 0 { Candidates::Withheld { found: masters_found, + count_is_lower_bound, } } else { Candidates::None @@ -1319,7 +1363,11 @@ fn unresolved_from( .collect::>(); let found = masters_found.max(capped); if listed.len() < found { - Candidates::Truncated { listed, found } + Candidates::Truncated { + listed, + found, + count_is_lower_bound, + } } else { Candidates::Listed { listed } } @@ -1349,11 +1397,15 @@ fn remembered_candidates( identifier_matches: &BTreeSet, memo: &mut SearchMemo, ) -> (Vec<(usize, CandidateRule)>, usize) { - let key = (entity.key.clone(), identifier_matches.clone()); + let key = ( + entity.key.clone(), + entity.binding_key.clone(), + identifier_matches.clone(), + ); if let Some(remembered) = memo.seen.get(&key) { return remembered.clone(); } - let fingerprint = candidate_memo_fingerprint(&key.0, &key.1); + let fingerprint = candidate_memo_fingerprint(&key.0, &key.1, &key.2); let computed = collect_candidates(catalog, entity, identifier_matches); // Entry *count* alone does not bound a memo whose keys and values are // themselves collections, so the key is still size-tested. The **value** is @@ -1400,12 +1452,20 @@ fn candidate_memo_fingerprint_for_entity(catalog: &MasterCatalog, entity: &Sourc } } } - candidate_memo_fingerprint(&entity.key, &identifier_matches) + candidate_memo_fingerprint(&entity.key, &entity.binding_key, &identifier_matches) } -fn candidate_memo_fingerprint(key: &str, identifier_matches: &BTreeSet) -> u64 { +/// Must hash exactly what `CandidateMemoKey` holds. A fingerprint over less than +/// the key counts two entities as repeating when they do not, and admits to the +/// memo a result the second one must not be served. +fn candidate_memo_fingerprint( + key: &str, + binding_key: &str, + identifier_matches: &BTreeSet, +) -> u64 { let mut hasher = std::collections::hash_map::DefaultHasher::new(); key.hash(&mut hasher); + binding_key.hash(&mut hasher); identifier_matches.hash(&mut hasher); hasher.finish() } diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 340493bd..632a4ae7 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -1586,7 +1586,11 @@ fn the_listing_variant_says_what_an_absent_candidate_means() { .expect("unbound") .candidates, Candidates::Withheld { - found: MAX_PREFIX_FAMILY + 5 + found: MAX_PREFIX_FAMILY + 5, + // A prefix family is unioned with the listed candidates before it + // is withheld, so its count is exact — the listing is incomplete + // and the number is not a floor. Those are different questions. + count_is_lower_bound: false, }, "many exist and none separates them" ); @@ -1604,23 +1608,53 @@ fn only_an_incomplete_listing_may_withhold_an_absence() { // present". `None` permits that conclusion; the other two forbid it. assert!(!Candidates::None.is_incomplete()); assert!(!Candidates::Listed { listed: Vec::new() }.is_incomplete()); - assert!(Candidates::Withheld { found: 30 }.is_incomplete()); + assert!(Candidates::Withheld { + found: 30, + count_is_lower_bound: true + } + .is_incomplete()); assert!(Candidates::Truncated { listed: Vec::new(), - found: 9 + found: 9, + count_is_lower_bound: false, } .is_incomplete()); assert!(!Candidates::None.count_is_lower_bound()); assert!(!Candidates::Listed { listed: Vec::new() }.count_is_lower_bound()); - assert!(Candidates::Withheld { found: 30 }.count_is_lower_bound()); + assert!(Candidates::Withheld { + found: 30, + count_is_lower_bound: true + } + .count_is_lower_bound()); + // Incomplete and inexact are now independent: a truncated listing whose + // count is a true union reports `false` here, which is the whole point. + assert!(!Candidates::Truncated { + listed: Vec::new(), + found: 9, + count_is_lower_bound: false, + } + .count_is_lower_bound()); assert!(Candidates::Truncated { listed: Vec::new(), - found: 9 + found: 9, + count_is_lower_bound: true, } .count_is_lower_bound()); // `found` is the total, never the listed length, wherever it is known. - assert_eq!(Candidates::Withheld { found: 30 }.found(), 30); - assert!(Candidates::Withheld { found: 30 }.listed().is_empty()); + assert_eq!( + Candidates::Withheld { + found: 30, + count_is_lower_bound: true + } + .found(), + 30 + ); + assert!(Candidates::Withheld { + found: 30, + count_is_lower_bound: true + } + .listed() + .is_empty()); } // --- candidate discipline -------------------------------------------------- @@ -2698,8 +2732,12 @@ fn the_listing_word_is_the_one_the_wire_carries() { Candidates::Truncated { listed: vec![candidate], found: 9, + count_is_lower_bound: true, + }, + Candidates::Withheld { + found: 9, + count_is_lower_bound: true, }, - Candidates::Withheld { found: 9 }, ] { let json = serde_json::to_value(&candidates).expect("candidates serialize"); assert_eq!( @@ -2709,3 +2747,95 @@ fn the_listing_word_is_the_one_the_wire_carries() { ); } } + +#[test] +fn two_spellings_sharing_a_wide_key_do_not_share_a_memo_entry() { + // `collect_candidates` reads BOTH folds: the wide key for prefix and token + // rules, and the resolving key so that the masters which *caused* a name + // ambiguity are the ones listed. The memo key named only the wide fold, so + // two spellings that agree there and differ under the resolving fold shared + // an entry, and the second was served candidates justified by the first. + // + // `AB-CD` and `AB\u{2013}CD` are exactly that pair. `comparison_key` maps every + // dash variant to `-`, so the wide keys agree; `verified_fold` folds only + // ASCII `-` and `/`, so the resolving keys do not — which is §9.4d's + // measurement that an en dash is not a separator, reaching the memo. + let catalog = ledgers(&["AB CD", "AB\u{2013}CD", "Beta Supply"]); + + // The ASCII spelling resolves onto `AB CD`: hyphen and space are one + // separator, so this is a normalized bind. + let ascii = bind_one_name(&catalog, "ab-cd"); + assert_eq!(ascii.bound_name(), Some("AB CD")); + + // The en-dash spelling must reach its own master, not the other's result. + let dashed = bind_one_name(&catalog, "ab\u{2013}cd"); + assert_eq!( + dashed.bound_name(), + Some("AB\u{2013}CD"), + "the en-dash spelling was served the ASCII spelling's binding" + ); + + // And in one report, where the memo is actually consulted: the same two + // spellings twice each, so both keys repeat and both are cacheable. + let entities = ["ab-cd", "ab\u{2013}cd", "ab-cd", "ab\u{2013}cd"] + .iter() + .enumerate() + .map(|(position, name)| SourceEntity::new(position, name).expect("valid")) + .collect::>(); + let report = bound(&catalog, &entities); + let bound_names = report + .entities() + .iter() + .map(EntityBinding::bound_name) + .collect::>(); + assert_eq!( + bound_names, + [ + Some("AB CD"), + Some("AB\u{2013}CD"), + Some("AB CD"), + Some("AB\u{2013}CD") + ], + "a cached result crossed between two spellings of one wide key" + ); +} + +#[test] +fn an_exactly_counted_family_is_not_reported_as_a_floor() { + // Count precision and listing completeness are different questions, and + // deriving the first from the second told every consumer "at least 100" + // about a number that was 100. Under-claiming is the safe direction and it + // is still a wrong statement about the book — and it trains an operator to + // discount a hedge that elsewhere means something. + // + // A **prefix** family is unioned with the listed candidates in + // `collect_candidates` before the decision not to show it, so its count is + // a true union. An **identifier** family is not expanded at all, so the + // count is the larger of two possibly-overlapping sets. One is exact and + // withheld; the other is withheld and a floor. + let prefix = (0..MAX_PREFIX_FAMILY + 5) + .map(|index| format!("ALPHAGROUP {index:03}")) + .collect::>(); + let prefix_catalog = MasterCatalog::new(MasterClass::Ledger, &prefix).expect("valid"); + let withheld_exactly = bind_one_name(&prefix_catalog, "ALPHAGROUP"); + let candidates = &withheld_exactly.unresolved().expect("unbound").candidates; + assert_eq!(candidates.listing(), "withheld"); + assert_eq!(candidates.found(), MAX_PREFIX_FAMILY + 5); + assert!( + !candidates.count_is_lower_bound(), + "a prefix family is counted as a union, so its count is not a floor" + ); + + // The identifier family, by contrast, is a floor. + let shared = (0..MAX_CANDIDATES_PER_ENTITY + 5) + .map(|index| format!("Shared Party {index:03} (5550007777)")) + .collect::>(); + let shared_catalog = MasterCatalog::new(MasterClass::Ledger, &shared).expect("valid"); + let withheld_loosely = bind_one_name(&shared_catalog, "Zeta Holdings 5550007777"); + let candidates = &withheld_loosely.unresolved().expect("unbound").candidates; + assert!(candidates.is_incomplete()); + assert!( + candidates.count_is_lower_bound(), + "a skipped identifier family is the one case the count cannot be a union" + ); +} diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index a0a6afc9..6078f492 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -1532,7 +1532,14 @@ fn master_match_bounds_suggestions_before_copying_names_and_preserves_ambiguity( "master_binding_no_discriminating_candidate" ); assert_eq!(matched["candidate_count"], 100); - assert_eq!(matched["candidate_count_is_lower_bound"], true); + // Exactly 100, and said so. A **prefix** family is unioned with the listed + // candidates before the decision not to show it, so the count is a true + // union — only a skipped *identifier* family makes it a floor. This + // asserted `true` while the flag was derived from `is_incomplete()`, which + // told every consumer "at least 100" about a number that was 100. + assert_eq!(matched["candidate_count_is_lower_bound"], false); + // The listing is still incomplete, which is a different question and keeps + // its own field. assert_eq!(matched["candidates_truncated"], true); assert!(matched["candidates"].as_array().unwrap().is_empty()); // A family inside the bound is still listed in full. From 859ec8d948f818a0e7d66e17b6fe591f3ebbfc1a Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 18:56:34 +0530 Subject: [PATCH 54/75] fix: preserve master candidate count precision --- docs/adr/0016-master-binding-authority.md | 13 +-- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 +- scripts/source-draft-screen.test.tsx | 30 ++++++ .../bridge-tally-core/src/master_binding.rs | 82 ++++++++++----- .../src/master_binding_tests.rs | 99 ++++++++++++++++--- src-tauri/src/agent_import_tests.rs | 4 +- src-tauri/src/source_draft/catalog.rs | 6 +- 8 files changed, 188 insertions(+), 54 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 1a7feecd..9aaad4e2 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -301,10 +301,11 @@ Candidates are capped at `MAX_CANDIDATES_PER_ENTITY` (25). The core retains `candidate_count`, listing state and `Candidates::count_is_lower_bound()`. The MCP and desktop projections expose `candidate_count_is_lower_bound`: true means the number is a conservative lower bound and must be shown as "at least N". -A withheld or core-truncated listing does not establish an exact union count. -A later consumer-only copy cap can shorten a complete listing while retaining -an exact count; `candidates_truncated` alone therefore does not describe count -precision. +The core sets it only when unmaterialized identifier families prevent an exact +union count. A withheld prefix family or a core-truncated list can retain an +exact union count; listing completeness and count precision are separate facts. +A later consumer-only copy cap can likewise shorten a complete listing while +retaining an exact count. ### 4a. An empty candidate list is three different facts, and the producer says which @@ -315,11 +316,11 @@ things to anyone deciding what to do next: | --- | --- | | `NoCandidate` | no master resembles this name at all | | `NoDiscriminatingCandidate` | at least `candidate_count` masters resemble it when the count is a lower bound, and none is separable — **many exist**, none is worth showing | -| any, with `candidates_truncated` | the list was cut, by the per-entity cap or by the report's aggregate byte budget | +| any, with `candidate_listing: "truncated"` | the list was cut, by the per-entity cap or by the report's aggregate byte budget | So `candidates.is_empty()` alone answers nothing. The disambiguators are `reason`, `candidate_count`, `candidate_count_is_lower_bound` and -`candidates_truncated`, and a consumer that +`candidate_listing`, and a consumer that reads the empty vector as "nothing exists" is wrong in two cases out of three. This is stated here, in the producer's contract, rather than left to each diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 8177ee9b..f5e38363 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "72506a95450cad82ef2f98eb15ec7942b0a3cc662c945d3d2a2816ee51ad2eb7", + "compatibility_surface_sha256": "d4bfdfc99d50904cee28f7a49254d39e0641184ce5eb2866bb177c451a6d6937", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index c20bf176..21fa19f2 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "1e6498291f83cf18c7b3f4f2d7dac51172312577027d6e676c35b47c1da0023b" + "sha256": "1a76b520d4d467c4bdcdb2f8f0432c7833233a47a33ab4fb5be89acdf671ef3e" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -583,7 +583,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "3b133a5830fdf64ca5459c7eec57d503bebb67a2da69cee8587aa499af26a534" + "sha256": "9b6fce29bb15eb0f71e48bcd364244b1e16893e13f24050cead465642a640184" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "72506a95450cad82ef2f98eb15ec7942b0a3cc662c945d3d2a2816ee51ad2eb7" + "manifest_sha256": "d4bfdfc99d50904cee28f7a49254d39e0641184ce5eb2866bb177c451a6d6937" } \ No newline at end of file diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index 8689405c..bf07e76b 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -848,6 +848,36 @@ test("a family withheld under a different reason is not reported as a full repor root.unmount(); }); +test("a materialized withheld family keeps its exact count", async () => { + // Listing state and count precision are independent: a full prefix-family + // union may deliberately withhold names without making its count an estimate. + const withheldFamily = { + ...catalog, + targets: ["DN Party 001", "DN Party 002", "DN Party 003"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: null, + bound_basis: null, + unbound_reason: "master_binding_no_discriminating_candidate", + candidates: [], + candidate_count: 30, + candidate_count_is_lower_bound: false, + candidate_listing: "withheld", + }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(withheldFamily); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "company-one" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + + expect(host.textContent).toContain("matches 30 existing ledgers and tells them apart from none of them, so none is listed"); + expect(host.textContent).not.toContain("matches at least 30 existing ledgers"); + root.unmount(); +}); + test("a source line that separates no ledger says so instead of counting nothing", async () => { // The state the live measurement made necessary: the name reaches a whole // family and tells none of them apart, so listing an arbitrary slice would diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 85f4d7b2..85070cc8 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -315,11 +315,15 @@ pub enum Candidates { Truncated { listed: Vec, found: usize, + count_is_lower_bound: bool, }, /// A family this name reaches and separates none of: counted, and /// deliberately not listed, because an arbitrary slice of it put the right /// master out of view about a third of the time against live books. - Withheld { found: usize }, + Withheld { + found: usize, + count_is_lower_bound: bool, + }, } impl Candidates { @@ -350,24 +354,34 @@ impl Candidates { } /// Masters found before any truncation or withholding. The value is a - /// lower bound when the listing is incomplete; use + /// lower bound only when unmaterialized identifier families prevent their + /// union from being counted; use /// [`Self::count_is_lower_bound`] before presenting it as exact. pub fn found(&self) -> usize { match self { Self::None => 0, Self::Listed { listed } => listed.len(), - Self::Truncated { found, .. } | Self::Withheld { found } => *found, + Self::Truncated { found, .. } | Self::Withheld { found, .. } => *found, } } - /// Whether `found()` is conservative because the report withheld or - /// truncated part of the evidence. Large identifier families are not - /// expanded, so overlapping families cannot be distinguished from one - /// another without materializing them. Keeping this fact beside the count - /// prevents a projection from turning a sound lower bound into a false - /// exact total. + /// Whether `found()` is conservative because large identifier families are + /// not expanded, so their overlapping union cannot be counted without + /// materializing it. Listing truncation alone does not make a count + /// inexact: prefix and report-cap results can retain an exact union while + /// showing only part of it. pub fn count_is_lower_bound(&self) -> bool { - self.is_incomplete() + match self { + Self::None | Self::Listed { .. } => false, + Self::Truncated { + count_is_lower_bound, + .. + } + | Self::Withheld { + count_is_lower_bound, + .. + } => *count_is_lower_bound, + } } /// Whether masters exist that are not in `listed()`. The predicate a @@ -936,10 +950,12 @@ pub fn bind( // 1,024 distinct cheap misses at the head of a draft filled it, and the // repeated expensive key behind them was then never cached. // - // The key is the source fold plus the masters its identifiers reached, not - // the raw identifier list. Different unmatched hints all reach the same - // empty set, so proxy-counting the raw lists re-ran their one expensive - // candidate search once per row. Fingerprints keep this prepass bounded by + // The key is both source folds plus the masters its identifiers reached, + // not the raw identifier list. Different unmatched hints all reach the + // same empty set, so proxy-counting the raw lists re-ran their one + // expensive candidate search once per row. Both folds are necessary: + // `collect_candidates` also reads `binding_key` for a `NormalizedEqual` + // candidate. Fingerprints keep this prepass bounded by // `MAX_SOURCE_ENTITIES` without holding another owned key/set per entity; // the memo itself remains capped and checks the full key before reuse. let repeated = repeated_candidate_memo_fingerprints(catalog, entities); @@ -959,8 +975,7 @@ pub fn bind( } /// What `collect_candidates` produced for one distinct source name, keyed by -/// the only two things it reads: the source key, and the masters the entity's -/// identifiers reached. +/// the two source folds and the masters the entity's identifiers reached. /// /// A draft may repeat one ledger name across its rows, and the search is not /// cheap when it does: a truncated name against a large prefix family @@ -971,7 +986,7 @@ pub fn bind( /// candidate list it holds — a draft of 40,000 *distinct* names would trade the /// stall for the memory the aggregate bounds elsewhere exist to prevent. The /// repeated-name case, which is the one that stalls, needs very few entries. -type CandidateMemoKey = (String, BTreeSet); +type CandidateMemoKey = (String, String, BTreeSet); type CandidateMemo = BTreeMap, usize)>; /// One run's search scratch: what has already been computed, and which source @@ -1085,7 +1100,9 @@ fn bind_one( // it, so adding them is sound and costs nothing but a lookup: the union is // never built, which is the whole point of skipping. It stays a **lower // bound** — two disjoint skipped families are still counted as the larger - // alone — and that is what `Candidates::Withheld` means. Over-counting + // alone. `Candidates::Withheld` records that no individual names were + // listed; its separate precision flag records this lower-bound case. + // Over-counting // would be worse than under-counting here: two identifiers can be held by // overlapping families, so summing their sizes would state a number of // masters that do not exist. @@ -1236,6 +1253,7 @@ fn bind_one( reason, candidates, masters_found.max(withheld_holders), + withheld_holders > 0, budget, ) } @@ -1279,6 +1297,7 @@ fn unresolved_status( reason, candidates, masters_found.max(withheld_holders), + withheld_holders > 0, budget, ) } @@ -1289,6 +1308,7 @@ fn unresolved_from( reason: UnboundReason, candidates: Vec<(usize, CandidateRule)>, masters_found: usize, + count_is_lower_bound: bool, budget: &mut usize, ) -> BindingStatus { let mut ordered = candidates; @@ -1299,6 +1319,7 @@ fn unresolved_from( if masters_found > 0 { Candidates::Withheld { found: masters_found, + count_is_lower_bound, } } else { Candidates::None @@ -1319,7 +1340,11 @@ fn unresolved_from( .collect::>(); let found = masters_found.max(capped); if listed.len() < found { - Candidates::Truncated { listed, found } + Candidates::Truncated { + listed, + found, + count_is_lower_bound, + } } else { Candidates::Listed { listed } } @@ -1349,11 +1374,15 @@ fn remembered_candidates( identifier_matches: &BTreeSet, memo: &mut SearchMemo, ) -> (Vec<(usize, CandidateRule)>, usize) { - let key = (entity.key.clone(), identifier_matches.clone()); + let key = ( + entity.key.clone(), + entity.binding_key.clone(), + identifier_matches.clone(), + ); if let Some(remembered) = memo.seen.get(&key) { return remembered.clone(); } - let fingerprint = candidate_memo_fingerprint(&key.0, &key.1); + let fingerprint = candidate_memo_fingerprint(&key.0, &key.1, &key.2); let computed = collect_candidates(catalog, entity, identifier_matches); // Entry *count* alone does not bound a memo whose keys and values are // themselves collections, so the key is still size-tested. The **value** is @@ -1365,7 +1394,7 @@ fn remembered_candidates( // through shared tokens re-ran it once per row. debug_assert!(computed.0.len() <= MAX_CANDIDATES_PER_ENTITY); let worth_holding = - memo.repeated.contains(&fingerprint) && key.1.len() <= MAX_CANDIDATES_PER_ENTITY; + memo.repeated.contains(&fingerprint) && key.2.len() <= MAX_CANDIDATES_PER_ENTITY; if worth_holding && memo.seen.len() < MAX_CANDIDATE_MEMO_ENTRIES { memo.seen.insert(key, computed.clone()); } @@ -1400,12 +1429,17 @@ fn candidate_memo_fingerprint_for_entity(catalog: &MasterCatalog, entity: &Sourc } } } - candidate_memo_fingerprint(&entity.key, &identifier_matches) + candidate_memo_fingerprint(&entity.key, &entity.binding_key, &identifier_matches) } -fn candidate_memo_fingerprint(key: &str, identifier_matches: &BTreeSet) -> u64 { +fn candidate_memo_fingerprint( + key: &str, + binding_key: &str, + identifier_matches: &BTreeSet, +) -> u64 { let mut hasher = std::collections::hash_map::DefaultHasher::new(); key.hash(&mut hasher); + binding_key.hash(&mut hasher); identifier_matches.hash(&mut hasher); hasher.finish() } diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 340493bd..002bf9f7 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -907,8 +907,8 @@ fn a_catalog_is_bounded_by_total_bytes_and_not_only_by_count() { fn repeating_one_source_name_does_not_repeat_the_search_or_change_the_answer() { // A draft may name one ledger on every row, and the candidate search is not // cheap when the name reaches a family. Remembering it must not change what - // the report says — the memo is keyed on the source key and the masters its - // identifiers reached, which is all `collect_candidates` reads. + // the report says — the memo is keyed on both source folds and the masters + // its identifiers reached, which are all `collect_candidates` reads. let names = (0..60) .map(|index| format!("Acme Branch {index:05}")) .collect::>(); @@ -980,6 +980,37 @@ fn repeating_one_source_name_does_not_repeat_the_search_or_change_the_answer() { ); } +#[test] +fn distinct_resolving_folds_never_share_a_candidate_memo_entry() { + // `comparison_key` normalizes the en dash to a hyphen, so both source + // spellings have one wide key. The resolving fold keeps the en dash as + // content, though: only the ASCII-hyphen spelling collides with both + // observed catalog names. Different unmatched hints still derive an empty + // identifier match set, which made the old `(wide_key, matches)` memo key + // hand the first candidate list to the second source. + let catalog = ledgers(&["AB/CD", "AB CD", "Beta Supply"]); + let entities = vec![ + SourceEntity::with_identifier_hints(0, "AB-CD", ["5550001001"]).expect("valid"), + SourceEntity::with_identifier_hints(1, "AB–CD", ["5550001002"]).expect("valid"), + ]; + + super::CANDIDATE_SEARCHES.with(|count| count.set(0)); + let report = bound(&catalog, &entities); + assert_eq!( + super::CANDIDATE_SEARCHES.with(std::cell::Cell::get), + 2, + "spellings with different resolving folds must each run their own search" + ); + assert_eq!(reason(&report.entities()[0]), UnboundReason::NameAmbiguous); + assert_eq!(candidate_names(&report.entities()[0]), ["AB CD", "AB/CD"]); + assert_eq!(reason(&report.entities()[1]), UnboundReason::NearMiss); + assert_eq!( + candidate_names(&report.entities()[1]), + ["AB CD"], + "the en-dash spelling must not inherit the ASCII-hyphen collision" + ); +} + #[test] fn a_retained_identity_stays_short_enough_to_write_back() { // An unresolved entity carries its identifiers into a fallback so the money @@ -1580,16 +1611,21 @@ fn the_listing_variant_says_what_an_absent_candidate_means() { .map(|index| format!("ALPHAGROUP UNIT {index:02}")) .collect::>(); let family = MasterCatalog::new(MasterClass::Ledger, &family).expect("valid"); + let binding = bind_one_name(&family, "ALPHAGROUP"); + let withheld = &binding.unresolved().expect("unbound").candidates; assert_eq!( - bind_one_name(&family, "ALPHAGROUP") - .unresolved() - .expect("unbound") - .candidates, - Candidates::Withheld { - found: MAX_PREFIX_FAMILY + 5 + withheld, + &Candidates::Withheld { + found: MAX_PREFIX_FAMILY + 5, + count_is_lower_bound: false, }, "many exist and none separates them" ); + assert!(withheld.is_incomplete()); + assert!( + !withheld.count_is_lower_bound(), + "the prefix family was materialized, so its union is exact" + ); let listed = ledgers(&["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA", "Beta Supply"]); let binding = bind_one_name(&listed, "ALPHA"); @@ -1604,23 +1640,50 @@ fn only_an_incomplete_listing_may_withhold_an_absence() { // present". `None` permits that conclusion; the other two forbid it. assert!(!Candidates::None.is_incomplete()); assert!(!Candidates::Listed { listed: Vec::new() }.is_incomplete()); - assert!(Candidates::Withheld { found: 30 }.is_incomplete()); + assert!(Candidates::Withheld { + found: 30, + count_is_lower_bound: false, + } + .is_incomplete()); assert!(Candidates::Truncated { listed: Vec::new(), - found: 9 + found: 9, + count_is_lower_bound: false, } .is_incomplete()); assert!(!Candidates::None.count_is_lower_bound()); assert!(!Candidates::Listed { listed: Vec::new() }.count_is_lower_bound()); - assert!(Candidates::Withheld { found: 30 }.count_is_lower_bound()); - assert!(Candidates::Truncated { + assert!(!Candidates::Withheld { + found: 30, + count_is_lower_bound: false, + } + .count_is_lower_bound()); + assert!(!Candidates::Truncated { listed: Vec::new(), - found: 9 + found: 9, + count_is_lower_bound: false, + } + .count_is_lower_bound()); + assert!(Candidates::Withheld { + found: 30, + count_is_lower_bound: true, } .count_is_lower_bound()); // `found` is the total, never the listed length, wherever it is known. - assert_eq!(Candidates::Withheld { found: 30 }.found(), 30); - assert!(Candidates::Withheld { found: 30 }.listed().is_empty()); + assert_eq!( + Candidates::Withheld { + found: 30, + count_is_lower_bound: false, + } + .found(), + 30 + ); + assert!(Candidates::Withheld { + found: 30, + count_is_lower_bound: false, + } + .listed() + .is_empty()); } // --- candidate discipline -------------------------------------------------- @@ -2698,8 +2761,12 @@ fn the_listing_word_is_the_one_the_wire_carries() { Candidates::Truncated { listed: vec![candidate], found: 9, + count_is_lower_bound: false, + }, + Candidates::Withheld { + found: 9, + count_is_lower_bound: true, }, - Candidates::Withheld { found: 9 }, ] { let json = serde_json::to_value(&candidates).expect("candidates serialize"); assert_eq!( diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index a0a6afc9..9a5eba6d 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -1532,7 +1532,9 @@ fn master_match_bounds_suggestions_before_copying_names_and_preserves_ambiguity( "master_binding_no_discriminating_candidate" ); assert_eq!(matched["candidate_count"], 100); - assert_eq!(matched["candidate_count_is_lower_bound"], true); + // The prefix family was fully materialized even though its names are + // deliberately withheld from the listing, so this is an exact union. + assert_eq!(matched["candidate_count_is_lower_bound"], false); assert_eq!(matched["candidates_truncated"], true); assert!(matched["candidates"].as_array().unwrap().is_empty()); // A family inside the bound is still listed in full. diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index f260caf4..82d9a0a9 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -98,9 +98,9 @@ pub(crate) struct SourceDraftCatalogBinding { pub(crate) unbound_reason: Option<&'static str>, pub(crate) candidates: Vec, pub(crate) candidate_count: usize, - /// `true` when the core could only establish a lower bound because part - /// of the candidate evidence was withheld or truncated. The number must - /// then be rendered as "at least N" rather than as an exact total. + /// `true` when unmaterialized identifier families prevent the core from + /// establishing an exact union. A withheld or truncated listing can still + /// have an exact count; only this flag requires rendering "at least N". pub(crate) candidate_count_is_lower_bound: bool, /// Which of the four candidate states this is, in the word the core type /// already tags its serialized form with. Carried rather than inferred: an From b4644eed5eb21ba6d0d5e65a7340c1709b9867c6 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 19:09:56 +0530 Subject: [PATCH 55/75] fix(master-binding): name the memo key so widening it cannot re-point a guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CandidateMemoKey` was `(String, BTreeSet)`. Adding the resolving fold as a second `String` silently re-pointed every positional access, and the memo's size guard — `key.1.len()` — went from bounding the identifier-holder set to measuring the binding key's bytes against a candidate-count cap. Both `String` and `BTreeSet` answer `.len()`, so nothing failed to compile. Both directions broke at once. A repeated unresolved name longer than 25 bytes was never cached, so the stall this memo exists to prevent came back through the front door after two rounds of closing side doors on it — and ledger names are routinely longer than 25 bytes. Large holder sets meanwhile lost their bound entirely, which is the memory the test was there to protect. The key is a named struct now. `key.identifier_matches.len()` says what it measures, and the type carries why it is not a tuple, so the next person to widen it is told what widening a tuple did last time. `a_long_repeated_name_is_still_cached` repeats a 37-byte name six times and asserts one search. Mutation-checked against the defect as it shipped. Co-Authored-By: Claude Opus 5 --- .../bridge-tally-core/src/master_binding.rs | 37 ++++++++++++++----- .../src/master_binding_tests.rs | 29 +++++++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index fc1dcfde..e4a1cdb3 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1008,7 +1008,25 @@ pub fn bind( /// served one spelling's candidates to another. `AB-CD` and `AB\u{2013}CD` share a /// wide key, because `comparison_key` maps every dash variant to `-`, and /// differ under the resolving fold, because only ASCII `-` and `/` fold there. -type CandidateMemoKey = (String, String, BTreeSet); +/// **Named, not a tuple, and that is the point.** This began as +/// `(String, BTreeSet)`; adding the resolving fold as a second `String` +/// silently re-pointed every positional access, and the memo's size guard — +/// written as `key.1.len()` — went from bounding the identifier-holder *set* to +/// measuring the binding key's *bytes* against a candidate-count cap. Both +/// directions broke at once: a repeated name over 25 bytes was never cached, so +/// the stall this memo exists to prevent came back, and large holder sets lost +/// their guard entirely. `String` and `BTreeSet` both answer `.len()`, so +/// nothing failed to compile. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct CandidateMemoKey { + /// The wide fold: which masters are worth showing. + key: String, + /// The resolving fold. `collect_candidates` reads it too, so two spellings + /// that agree on the wide fold and differ here are different questions. + binding_key: String, + /// The masters the entity's identifiers reached. + identifier_matches: BTreeSet, +} type CandidateMemo = BTreeMap, usize)>; /// One run's search scratch: what has already been computed, and which source @@ -1397,15 +1415,16 @@ fn remembered_candidates( identifier_matches: &BTreeSet, memo: &mut SearchMemo, ) -> (Vec<(usize, CandidateRule)>, usize) { - let key = ( - entity.key.clone(), - entity.binding_key.clone(), - identifier_matches.clone(), - ); + let key = CandidateMemoKey { + key: entity.key.clone(), + binding_key: entity.binding_key.clone(), + identifier_matches: identifier_matches.clone(), + }; if let Some(remembered) = memo.seen.get(&key) { return remembered.clone(); } - let fingerprint = candidate_memo_fingerprint(&key.0, &key.1, &key.2); + let fingerprint = + candidate_memo_fingerprint(&key.key, &key.binding_key, &key.identifier_matches); let computed = collect_candidates(catalog, entity, identifier_matches); // Entry *count* alone does not bound a memo whose keys and values are // themselves collections, so the key is still size-tested. The **value** is @@ -1416,8 +1435,8 @@ fn remembered_candidates( // search the memo exists for, and a name reaching twenty thousand masters // through shared tokens re-ran it once per row. debug_assert!(computed.0.len() <= MAX_CANDIDATES_PER_ENTITY); - let worth_holding = - memo.repeated.contains(&fingerprint) && key.1.len() <= MAX_CANDIDATES_PER_ENTITY; + let worth_holding = memo.repeated.contains(&fingerprint) + && key.identifier_matches.len() <= MAX_CANDIDATES_PER_ENTITY; if worth_holding && memo.seen.len() < MAX_CANDIDATE_MEMO_ENTRIES { memo.seen.insert(key, computed.clone()); } diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 632a4ae7..04bae394 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -2839,3 +2839,32 @@ fn an_exactly_counted_family_is_not_reported_as_a_floor() { "a skipped identifier family is the one case the count cannot be a union" ); } + +#[test] +fn a_long_repeated_name_is_still_cached() { + // The memo's size guard bounds the identifier-holder **set**, because that + // set is part of every key the memo stores. Written positionally as + // `key.1.len()`, it survived the key gaining a second `String` and started + // measuring the binding key's *bytes* against a candidate-count cap + // instead — so a repeated name of more than 25 bytes was never cached, and + // the stall the memo exists to prevent came back for exactly the drafts + // most likely to hit it. Ledger names are routinely longer than 25 bytes. + let name = "Zeta Placeholder Holdings Alpha Branch"; + assert!( + name.len() > MAX_CANDIDATES_PER_ENTITY, + "this fixture only bites if the name is longer than the candidate cap" + ); + let catalog = ledgers(&["Omega Supply", "Beta Supply"]); + let entities = (0..6) + .map(|position| SourceEntity::new(position, name).expect("valid")) + .collect::>(); + + super::CANDIDATE_SEARCHES.with(|count| count.set(0)); + let report = bound(&catalog, &entities); + let searches = super::CANDIDATE_SEARCHES.with(std::cell::Cell::get); + assert_eq!(report.totals().requested, 6); + assert_eq!( + searches, 1, + "a repeated name was searched {searches} times because it is long" + ); +} From 0ca005b3d5c5ef5398565ec871673b8f528b0707 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 19:27:44 +0530 Subject: [PATCH 56/75] fix(binding): report exact single-family counts --- docs/adr/0016-master-binding-authority.md | 1 + .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +-- .../bridge-tally-core/src/master_binding.rs | 31 ++++++++++++++----- .../src/master_binding_tests.rs | 26 ++++++++++++++-- 5 files changed, 51 insertions(+), 13 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 9aaad4e2..8df29eeb 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -316,6 +316,7 @@ things to anyone deciding what to do next: | --- | --- | | `NoCandidate` | no master resembles this name at all | | `NoDiscriminatingCandidate` | at least `candidate_count` masters resemble it when the count is a lower bound, and none is separable — **many exist**, none is worth showing | +| any, with `candidate_listing: "withheld"` | a family is deliberately not sliced; `candidate_count_is_lower_bound` says whether multiple unmaterialized families leave its union uncertain | | any, with `candidate_listing: "truncated"` | the list was cut, by the per-entity cap or by the report's aggregate byte budget | So `candidates.is_empty()` alone answers nothing. The disambiguators are diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index f5e38363..983f9371 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "d4bfdfc99d50904cee28f7a49254d39e0641184ce5eb2866bb177c451a6d6937", + "compatibility_surface_sha256": "459c3a9aac6ef29050b7ab828a74a80e969f8a45ce07ebb0fcd4b22e7aa77c69", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 21fa19f2..ee4a500f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "1a76b520d4d467c4bdcdb2f8f0432c7833233a47a33ab4fb5be89acdf671ef3e" + "sha256": "28e27565b2883163b88527317003b25ef77cc301e7d886cc663567698325dbeb" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "d4bfdfc99d50904cee28f7a49254d39e0641184ce5eb2866bb177c451a6d6937" + "manifest_sha256": "459c3a9aac6ef29050b7ab828a74a80e969f8a45ce07ebb0fcd4b22e7aa77c69" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 85070cc8..4e3cd2f7 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1006,6 +1006,9 @@ struct IdentifierEvidence<'a> { /// skipped family, plus the listed masters that are not in it. Present so a /// withheld listing can still say how many masters are involved. withheld_holders: usize, + /// Whether multiple distinct unmaterialized families leave their union + /// uncertain. A single skipped family plus materialized matches is exact. + withheld_count_is_lower_bound: bool, } struct SearchMemo { @@ -1042,6 +1045,9 @@ fn bind_one( // withheld family reported `found() == 0` and `listing: "none"`, telling // the operator nothing shares the identifier when hundreds do. let mut withheld_holders = 0_usize; + // Keep references rather than cloning large holder vectors. Distinct + // skipped families are the only source of unknown overlap in this union. + let mut withheld_families: Vec<&Vec> = Vec::new(); // The holders of the largest skipped family, kept by reference so the count // below can ask which listed masters are *not* in it. Nothing is cloned. let mut largest_withheld: Option<&Vec> = None; @@ -1056,6 +1062,9 @@ fn bind_one( // before the candidate memo is even consulted. if holders.len() > MAX_CANDIDATES_PER_ENTITY { identifier_conflict = true; + if !withheld_families.iter().any(|family| *family == holders) { + withheld_families.push(holders); + } if holders.len() > withheld_holders { withheld_holders = holders.len(); largest_withheld = Some(holders); @@ -1093,15 +1102,15 @@ fn bind_one( // One family's size is not the size of their union. An entity carrying two // identifiers — one held by thirty masters and skipped, one reaching a - // thirty-first — reported thirty, because the larger of the two counts - // ignores every master the other identifier listed. + // thirty-first — must count the fully materialized master too. // // The listed masters that are *not* in the skipped family are disjoint from // it, so adding them is sound and costs nothing but a lookup: the union is - // never built, which is the whole point of skipping. It stays a **lower - // bound** — two disjoint skipped families are still counted as the larger - // alone. `Candidates::Withheld` records that no individual names were - // listed; its separate precision flag records this lower-bound case. + // never built, which is the whole point of skipping. One skipped family is + // therefore exact; two distinct skipped families may overlap, so they are + // counted conservatively as the larger alone. `Candidates::Withheld` + // records that no individual names were listed; its separate precision + // flag records only that latter lower-bound case. // Over-counting // would be worse than under-counting here: two identifiers can be held by // overlapping families, so summing their sizes would state a number of @@ -1120,6 +1129,7 @@ fn bind_one( } None => withheld_holders, }; + let withheld_count_is_lower_bound = withheld_families.len() > 1; // An identifier shared by two masters, and an entity whose identifiers // reach two masters, are the same refusal: the operator has a naming @@ -1159,6 +1169,7 @@ fn bind_one( exact, matches: &identifier_matches, withheld_holders, + withheld_count_is_lower_bound, }, budget, memo, @@ -1180,6 +1191,7 @@ fn bind_one( exact, matches: &identifier_matches, withheld_holders, + withheld_count_is_lower_bound, }, budget, memo, @@ -1221,6 +1233,7 @@ fn bind_one( exact, matches: &identifier_matches, withheld_holders, + withheld_count_is_lower_bound, }, budget, memo, @@ -1233,6 +1246,7 @@ fn bind_one( exact, matches: &identifier_matches, withheld_holders, + withheld_count_is_lower_bound, }, budget, memo, @@ -1253,7 +1267,7 @@ fn bind_one( reason, candidates, masters_found.max(withheld_holders), - withheld_holders > 0, + withheld_count_is_lower_bound, budget, ) } @@ -1279,6 +1293,7 @@ fn unresolved_status( exact, matches: identifier_matches, withheld_holders, + withheld_count_is_lower_bound, } = evidence; let (mut candidates, masters_found) = remembered_candidates(catalog, entity, identifier_matches, memo); @@ -1297,7 +1312,7 @@ fn unresolved_status( reason, candidates, masters_found.max(withheld_holders), - withheld_holders > 0, + withheld_count_is_lower_bound, budget, ) } diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 002bf9f7..c702ba56 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -2499,11 +2499,33 @@ fn a_withheld_family_counts_the_masters_the_other_identifier_listed_too() { "this fixture no longer exercises the skip" ); assert!( - unresolved.candidates.count_is_lower_bound(), - "the skipped identifier family makes this count conservative" + !unresolved.candidates.count_is_lower_bound(), + "one skipped family plus fully materialized matches has an exact union" ); } +#[test] +fn a_single_withheld_identifier_family_has_an_exact_count() { + let names = (0..MAX_CANDIDATES_PER_ENTITY + 5) + .map(|index| format!("Shared Party {index:03} (5550007777)")) + .collect::>(); + let family = names.len(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let entity = + SourceEntity::with_identifier_hints(0, "Unrelated Source", ["5550007777"]).expect("valid"); + + let binding = bound(&catalog, &[entity]) + .entities() + .first() + .cloned() + .expect("one entity in, one binding out"); + let unresolved = binding.unresolved().expect("identifier conflict"); + assert_eq!(reason(&binding), UnboundReason::IdentifierConflict); + assert_eq!(unresolved.candidates.listing(), "withheld"); + assert_eq!(unresolved.candidates.found(), family); + assert!(!unresolved.candidates.count_is_lower_bound()); +} + #[test] fn disjoint_withheld_identifier_families_are_marked_as_a_lower_bound() { let mut names = (0..MAX_CANDIDATES_PER_ENTITY + 5) From f15a0b714027b1bd13b1777d5bd3add258f41157 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 19:35:50 +0530 Subject: [PATCH 57/75] fix(binding): bound withheld union precision --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 118 ++++++++++-------- .../src/master_binding_tests.rs | 62 +++++++++ 4 files changed, 128 insertions(+), 58 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 983f9371..9fc8581c 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "459c3a9aac6ef29050b7ab828a74a80e969f8a45ce07ebb0fcd4b22e7aa77c69", + "compatibility_surface_sha256": "2e62b9d82a3d682c546c68359ae12896c0e6253808742e1a465a45190908979c", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index ee4a500f..5d7c935a 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "28e27565b2883163b88527317003b25ef77cc301e7d886cc663567698325dbeb" + "sha256": "8c80ac0601ba456f50c60591a8e106df792c0990d80ba3f4b6faf6ed6a180b94" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "459c3a9aac6ef29050b7ab828a74a80e969f8a45ce07ebb0fcd4b22e7aa77c69" + "manifest_sha256": "2e62b9d82a3d682c546c68359ae12896c0e6253808742e1a465a45190908979c" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 4e3cd2f7..c7ce8983 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1001,13 +1001,11 @@ struct IdentifierEvidence<'a> { exact: Option, /// Every master the entity's identifiers reached. matches: &'a BTreeSet, - /// A **lower bound** on how many masters share this entity's identifiers, - /// counted without expanding the families that were skipped: the largest - /// skipped family, plus the listed masters that are not in it. Present so a - /// withheld listing can still say how many masters are involved. - withheld_holders: usize, - /// Whether multiple distinct unmaterialized families leave their union - /// uncertain. A single skipped family plus materialized matches is exact. + /// The largest identifier family that was deliberately not materialized. + /// Its members remain available for bounded membership checks when the + /// candidate union is counted. + largest_withheld: Option<&'a [usize]>, + /// Whether more than one distinct skipped family may overlap the largest. withheld_count_is_lower_bound: bool, } @@ -1040,11 +1038,6 @@ fn bind_one( let mut identifier_matches = BTreeSet::new(); let mut identifier_conflict = false; let mut large_holder_points_elsewhere = false; - // How many masters a skipped family actually held. The set is not built, - // but the *count* is the one thing a reader still needs: without it a - // withheld family reported `found() == 0` and `listing: "none"`, telling - // the operator nothing shares the identifier when hundreds do. - let mut withheld_holders = 0_usize; // Keep references rather than cloning large holder vectors. Distinct // skipped families are the only source of unknown overlap in this union. let mut withheld_families: Vec<&Vec> = Vec::new(); @@ -1062,11 +1055,13 @@ fn bind_one( // before the candidate memo is even consulted. if holders.len() > MAX_CANDIDATES_PER_ENTITY { identifier_conflict = true; - if !withheld_families.iter().any(|family| *family == holders) { + if !withheld_families + .iter() + .any(|family| std::ptr::eq(*family, holders)) + { withheld_families.push(holders); } - if holders.len() > withheld_holders { - withheld_holders = holders.len(); + if largest_withheld.is_none_or(|family| holders.len() > family.len()) { largest_withheld = Some(holders); } // Skipping the expansion must not skip the *question* the @@ -1100,36 +1095,13 @@ fn bind_one( } } - // One family's size is not the size of their union. An entity carrying two - // identifiers — one held by thirty masters and skipped, one reaching a - // thirty-first — must count the fully materialized master too. - // - // The listed masters that are *not* in the skipped family are disjoint from - // it, so adding them is sound and costs nothing but a lookup: the union is - // never built, which is the whole point of skipping. One skipped family is - // therefore exact; two distinct skipped families may overlap, so they are - // counted conservatively as the larger alone. `Candidates::Withheld` - // records that no individual names were listed; its separate precision - // flag records only that latter lower-bound case. - // Over-counting - // would be worse than under-counting here: two identifiers can be held by - // overlapping families, so summing their sizes would state a number of - // masters that do not exist. - let withheld_holders = match largest_withheld { - Some(family) => { - // `by_identifier` is filled by pushing entry indices in ascending - // order, so each holder list is sorted and a membership test is a - // bisection rather than a scan of up to a whole catalog. - debug_assert!(family.is_sorted(), "holder lists are built in order"); - withheld_holders - + identifier_matches - .iter() - .filter(|index| family.binary_search(index).is_err()) - .count() - } - None => withheld_holders, - }; + // A single skipped family can be counted exactly once the bounded + // candidate set is known. Multiple distinct skipped families may overlap; + // retain the larger family as the lower-bound floor and mark the count + // uncertain without allocating their union. + let largest_withheld = largest_withheld.map(Vec::as_slice); let withheld_count_is_lower_bound = withheld_families.len() > 1; + debug_assert!(withheld_families.len() <= MAX_IDENTIFIERS_PER_NAME); // An identifier shared by two masters, and an entity whose identifiers // reach two masters, are the same refusal: the operator has a naming @@ -1168,7 +1140,7 @@ fn bind_one( IdentifierEvidence { exact, matches: &identifier_matches, - withheld_holders, + largest_withheld, withheld_count_is_lower_bound, }, budget, @@ -1190,7 +1162,7 @@ fn bind_one( IdentifierEvidence { exact, matches: &identifier_matches, - withheld_holders, + largest_withheld, withheld_count_is_lower_bound, }, budget, @@ -1232,7 +1204,7 @@ fn bind_one( IdentifierEvidence { exact, matches: &identifier_matches, - withheld_holders, + largest_withheld, withheld_count_is_lower_bound, }, budget, @@ -1245,7 +1217,7 @@ fn bind_one( IdentifierEvidence { exact, matches: &identifier_matches, - withheld_holders, + largest_withheld, withheld_count_is_lower_bound, }, budget, @@ -1266,7 +1238,8 @@ fn bind_one( entity, reason, candidates, - masters_found.max(withheld_holders), + masters_found, + largest_withheld, withheld_count_is_lower_bound, budget, ) @@ -1292,7 +1265,7 @@ fn unresolved_status( let IdentifierEvidence { exact, matches: identifier_matches, - withheld_holders, + largest_withheld, withheld_count_is_lower_bound, } = evidence; let (mut candidates, masters_found) = @@ -1311,7 +1284,8 @@ fn unresolved_status( entity, reason, candidates, - masters_found.max(withheld_holders), + masters_found, + largest_withheld, withheld_count_is_lower_bound, budget, ) @@ -1323,17 +1297,24 @@ fn unresolved_from( reason: UnboundReason, candidates: Vec<(usize, CandidateRule)>, masters_found: usize, - count_is_lower_bound: bool, + largest_withheld: Option<&[usize]>, + withheld_count_is_lower_bound: bool, budget: &mut usize, ) -> BindingStatus { let mut ordered = candidates; ordered.sort_by(|left, right| candidate_order(catalog, left, right)); + let (found, count_is_lower_bound) = candidate_count( + masters_found, + &ordered, + largest_withheld, + withheld_count_is_lower_bound, + ); // The variant is derived here, in one place, from the same facts that chose // the reason — so "empty" can never mean something the variant does not say. let candidates = if ordered.is_empty() { - if masters_found > 0 { + if found > 0 { Candidates::Withheld { - found: masters_found, + found, count_is_lower_bound, } } else { @@ -1353,7 +1334,7 @@ fn unresolved_from( }) }) .collect::>(); - let found = masters_found.max(capped); + let found = found.max(capped); if listed.len() < found { Candidates::Truncated { listed, @@ -1376,6 +1357,33 @@ fn unresolved_from( } } +/// Count a materialized candidate set against one borrowed withheld family. +/// The candidate vector is capped, so `masters_found > candidates.len()` means +/// there are additional unmaterialized name candidates whose overlap with the +/// large identifier family is unknown. No large family is copied or walked. +fn candidate_count( + masters_found: usize, + candidates: &[(usize, CandidateRule)], + largest_withheld: Option<&[usize]>, + withheld_count_is_lower_bound: bool, +) -> (usize, bool) { + let Some(family) = largest_withheld else { + return (masters_found, false); + }; + debug_assert!(family.is_sorted(), "holder lists are built in order"); + let outside_family = candidates + .iter() + .filter(|(index, _)| family.binary_search(index).is_err()) + .count(); + let known_union = family.len() + outside_family; + let uncertain = withheld_count_is_lower_bound || masters_found > candidates.len(); + if uncertain { + (known_union.max(masters_found), true) + } else { + (known_union, false) + } +} + /// `collect_candidates` behind its memo, and the only way to reach it. /// /// The first version of this memo sat inside `unresolved_status`, which reaches diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index c702ba56..1d21ff0c 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -2526,6 +2526,68 @@ fn a_single_withheld_identifier_family_has_an_exact_count() { assert!(!unresolved.candidates.count_is_lower_bound()); } +#[test] +fn a_single_withheld_family_counts_an_outside_name_candidate_exactly() { + let mut names = (0..MAX_CANDIDATES_PER_ENTITY + 5) + .map(|index| format!("Shared Party {index:03} (5550007777)")) + .collect::>(); + names.push("Zeta Supplier".to_string()); + let family = MAX_CANDIDATES_PER_ENTITY + 5; + let catalog = MasterCatalog::new(MasterClass::StockItem, &names).expect("valid"); + let entity = SourceEntity::with_identifier_hints(0, "Zeta", ["5550007777"]).expect("valid"); + + let binding = bound(&catalog, &[entity]) + .entities() + .first() + .cloned() + .expect("one entity in, one binding out"); + let unresolved = binding.unresolved().expect("identifier conflict"); + assert_eq!(unresolved.candidates.found(), family + 1); + assert!(!unresolved.candidates.count_is_lower_bound()); +} + +#[test] +fn a_name_candidate_inside_one_withheld_family_is_not_double_counted() { + let names = (0..MAX_CANDIDATES_PER_ENTITY + 5) + .map(|index| format!("Shared Party {index:03} (5550007777)")) + .collect::>(); + let family = names.len(); + let catalog = MasterCatalog::new(MasterClass::StockItem, &names).expect("valid"); + let entity = + SourceEntity::with_identifier_hints(0, "shared party 00 (5550007777)", []).expect("valid"); + + let binding = bound(&catalog, &[entity]) + .entities() + .first() + .cloned() + .expect("one entity in, one binding out"); + let unresolved = binding.unresolved().expect("near miss"); + assert_eq!(unresolved.candidates.found(), family); + assert!(!unresolved.candidates.count_is_lower_bound()); +} + +#[test] +fn an_unlisted_name_family_keeps_a_withheld_count_as_a_lower_bound() { + let mut names = (0..MAX_CANDIDATES_PER_ENTITY + 5) + .map(|index| format!("Shared Party {index:03} (5550007777)")) + .collect::>(); + names.extend( + (0..MAX_CANDIDATES_PER_ENTITY + 5).map(|index| format!("Zeta Supplier {index:02}")), + ); + let catalog = MasterCatalog::new(MasterClass::StockItem, &names).expect("valid"); + let entity = + SourceEntity::with_identifier_hints(0, "Zeta Supplier", ["5550007777"]).expect("valid"); + + let binding = bound(&catalog, &[entity]) + .entities() + .first() + .cloned() + .expect("one entity in, one binding out"); + let unresolved = binding.unresolved().expect("identifier conflict"); + assert_eq!(unresolved.candidates.listing(), "withheld"); + assert!(unresolved.candidates.count_is_lower_bound()); +} + #[test] fn disjoint_withheld_identifier_families_are_marked_as_a_lower_bound() { let mut names = (0..MAX_CANDIDATES_PER_ENTITY + 5) From dfe5a9a33269f31fd80f22edd68e838d339dfdcc Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 19:39:14 +0530 Subject: [PATCH 58/75] fix(master-binding): a materialized identifier match is not the name reaching something MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The precision predicate compared the search result against zero. `masters_found` counts everything `collect_candidates` found, and the materialized identifier matches are among them — they are offered as candidates — so a nonzero result does not mean the name reached anything. Those matches are *already* unioned into the withheld count exactly, by testing each against the skipped family. So one family skipped beside a second identifier reaching one distinct master reported "at least 31" while `a_withheld_family_counts_the_masters_the_other_identifier_listed_too` asserted, two lines earlier, that the total is exactly `family + 1`. A test claiming the number and a predicate denying it, in the same file. The comparison is now against what has already been accounted for: skipped_families > 1 || (skipped_families == 1 && searched > materialized) with the missing positive control added: one skipped family beside masters the **name** reached by token, where the overlap genuinely is unmeasured. Without it the clause could have been deleted silently. Three mutation controls, each caught by a different test — comparing against zero fails the mixed case, dropping the clause fails the by-name case, and `skipped_families > 0` fails both. Third round on this predicate, each narrowing it, and the through-line is the one this PR keeps finding: every correction made the number better and left the claim about it wider than the new truth. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 38 ++++++++++--------- .../src/master_binding_tests.rs | 32 +++++++++++++++- 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 290e6439..4596e1ab 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "e11399ac4f9522dbe27ea9ce00c2e799d4b47acd723f64a3010d1bb67c8ba509", + "compatibility_surface_sha256": "d8718c1227c6555bdd48042a6df66b76c389aef7c7ade73430b02cbb9f7f602c", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 8fae07f4..b7f0d19f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "a9ed95e281d632d5eacb0cc5b8a730273c4cc3fe42ecb2c1d96df3eeee9a42ec" + "sha256": "3922e05cc1423ac0d1656a3775354ee00d87e4554659bd95c33435eaa7523499" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "e11399ac4f9522dbe27ea9ce00c2e799d4b47acd723f64a3010d1bb67c8ba509" + "manifest_sha256": "d8718c1227c6555bdd48042a6df66b76c389aef7c7ade73430b02cbb9f7f602c" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index bc49a3a3..b209594e 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1291,7 +1291,7 @@ fn bind_one( reason, candidates, masters_found.max(withheld_holders), - count_is_uncertain(skipped_families, masters_found), + count_is_uncertain(skipped_families, masters_found, identifier_matches.len()), budget, ) } @@ -1336,7 +1336,7 @@ fn unresolved_status( reason, candidates, masters_found.max(withheld_holders), - count_is_uncertain(skipped_families, masters_found), + count_is_uncertain(skipped_families, masters_found, identifier_matches.len()), budget, ) } @@ -1657,23 +1657,27 @@ fn collect_candidates( /// Whether the reported total is a floor rather than a true union. /// -/// **Nonzero withholding is not the test**, which is what an earlier version of -/// this used. Two sets are in play: what the *name* reached, counted exactly by -/// `collect_candidates`, and what the *identifiers* reached, counted as the -/// largest skipped family union the listed masters. The total is their union, -/// and it is knowable in two cases: +/// Two sets are in play: what the search reached, counted exactly by +/// `collect_candidates`, and what the identifiers reached, counted as the +/// largest skipped family union the materialized matches. The total is their +/// union, and the question is only ever whether their overlap was measured. /// -/// - **nothing was skipped** — the identifier matches are already candidates, so -/// the name's count is the whole of it; -/// - **one family was skipped and the name reached nothing** — there is only one -/// set, and its size is exact. +/// **Two quantities look alike here and are not.** `searched` counts everything +/// `collect_candidates` found — and the materialized identifier matches are +/// among them, because they are offered as candidates. Those are *already* +/// unioned into the withheld count exactly, by testing each against the skipped +/// family. So a nonzero `searched` does not mean the name reached anything; it +/// may be entirely the identifier matches, and an earlier version of this +/// predicate read it that way and hedged a total it knew. What is unmeasured is +/// only the masters the **name** reached beyond those matches, which is +/// `searched > materialized`. /// -/// It is a floor only where the two could overlap in a way nobody measured: two -/// or more skipped families, whose mutual overlap is precisely what was not -/// materialized, or one skipped family beside masters the name reached, where -/// the family's overlap with those is equally unmeasured. -fn count_is_uncertain(skipped_families: usize, name_reached: usize) -> bool { - skipped_families > 1 || (skipped_families == 1 && name_reached > 0) +/// It is therefore a floor in exactly two situations: two or more skipped +/// families, whose mutual overlap is precisely what was not materialized; or +/// one skipped family beside masters reached by name alone, whose overlap with +/// that family is equally unmeasured. +fn count_is_uncertain(skipped_families: usize, searched: usize, materialized: usize) -> bool { + skipped_families > 1 || (skipped_families == 1 && searched > materialized) } /// How candidates are ordered wherever they are ordered: by the rule that diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index a577e09d..d40b63af 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -2498,9 +2498,15 @@ fn a_withheld_family_counts_the_masters_the_other_identifier_listed_too() { family + 1 > MAX_CANDIDATES_PER_ENTITY, "this fixture no longer exercises the skip" ); + // And **exact**, not a floor. The `family + 1` above is the union already: + // the materialized match was tested against the skipped family and found to + // be outside it. A nonzero search result does not mean the name reached + // anything — here it is entirely the identifier match, which has already + // been accounted for — so hedging this total would claim uncertainty about + // a number two lines above assert to be known. assert!( - unresolved.candidates.count_is_lower_bound(), - "the skipped identifier family makes this count conservative" + !unresolved.candidates.count_is_lower_bound(), + "the one skipped family and the listed match were already unioned exactly" ); } @@ -2897,6 +2903,28 @@ fn an_exactly_counted_family_is_not_reported_as_a_floor() { .count_is_lower_bound(), "two disjoint skipped families cannot be unioned without materializing them" ); + + // And one skipped family beside masters the **name** reached is a floor + // too — the positive control for the half of the predicate that is not + // about family count. Here the search finds two `Zeta Holdings` masters by + // token while the identifier's family is skipped, and whether those two sit + // inside that family is exactly what nobody measured. + let mut mixed = (0..MAX_CANDIDATES_PER_ENTITY + 5) + .map(|index| format!("Shared Party {index:03} (5550007777)")) + .collect::>(); + mixed.push("Zeta Holdings Alpha".to_string()); + mixed.push("Zeta Holdings Beta".to_string()); + let mixed_catalog = MasterCatalog::new(MasterClass::Ledger, &mixed).expect("valid"); + let by_name_too = bind_one_name(&mixed_catalog, "Zeta Holdings 5550007777"); + let candidates = &by_name_too.unresolved().expect("unbound").candidates; + assert!( + !candidates.listed().is_empty(), + "this fixture only bites if the name reached something of its own" + ); + assert!( + candidates.count_is_lower_bound(), + "masters reached by name may or may not sit inside the skipped family" + ); } #[test] From 9b5982651b9573e61f0bcd5b8e4550bddf62c117 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 19:45:35 +0530 Subject: [PATCH 59/75] refactor(binding): bound count facts and index invariant --- .../bridge-tally-core/src/master_binding.rs | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index c7ce8983..f3f066aa 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -840,6 +840,10 @@ impl MasterCatalog { by_token.entry(token.clone()).or_default().push(index); } } + debug_assert!( + by_identifier.values().all(|holders| holders.is_sorted()), + "identifier holder lists are built in entry order" + ); // A token carried by a large share of the catalog says nothing about // which master is meant. The threshold is measured from the catalog @@ -1009,6 +1013,11 @@ struct IdentifierEvidence<'a> { withheld_count_is_lower_bound: bool, } +struct CountEvidence<'a> { + largest_withheld: Option<&'a [usize]>, + withheld_count_is_lower_bound: bool, +} + struct SearchMemo { seen: CandidateMemo, /// Fingerprints of the full memo keys a later entity will ask for again. @@ -1071,14 +1080,6 @@ fn bind_one( // materialization. Dropping it made the invariant // size-dependent: a hint pointing entirely elsewhere let the // exact name bind, but only once the family grew past the cap. - // `by_identifier` is filled by pushing entry indices from the - // ascending `entries.iter().enumerate()` walk in `new`, so - // this exact holder vector is sorted by construction. Keep - // the assertion beside the lookup that relies on it. - debug_assert!( - holders.is_sorted(), - "identifier holder lists are built in entry order" - ); if exact.is_some_and(|index| holders.binary_search(&index).is_err()) { large_holder_points_elsewhere = true; } @@ -1239,8 +1240,10 @@ fn bind_one( reason, candidates, masters_found, - largest_withheld, - withheld_count_is_lower_bound, + CountEvidence { + largest_withheld, + withheld_count_is_lower_bound, + }, budget, ) } @@ -1285,8 +1288,10 @@ fn unresolved_status( reason, candidates, masters_found, - largest_withheld, - withheld_count_is_lower_bound, + CountEvidence { + largest_withheld, + withheld_count_is_lower_bound, + }, budget, ) } @@ -1297,8 +1302,7 @@ fn unresolved_from( reason: UnboundReason, candidates: Vec<(usize, CandidateRule)>, masters_found: usize, - largest_withheld: Option<&[usize]>, - withheld_count_is_lower_bound: bool, + count_evidence: CountEvidence<'_>, budget: &mut usize, ) -> BindingStatus { let mut ordered = candidates; @@ -1306,8 +1310,8 @@ fn unresolved_from( let (found, count_is_lower_bound) = candidate_count( masters_found, &ordered, - largest_withheld, - withheld_count_is_lower_bound, + count_evidence.largest_withheld, + count_evidence.withheld_count_is_lower_bound, ); // The variant is derived here, in one place, from the same facts that chose // the reason — so "empty" can never mean something the variant does not say. @@ -1370,7 +1374,6 @@ fn candidate_count( let Some(family) = largest_withheld else { return (masters_found, false); }; - debug_assert!(family.is_sorted(), "holder lists are built in order"); let outside_family = candidates .iter() .filter(|(index, _)| family.binary_search(index).is_err()) From 96f5666133f7d9b31bd416055b7932ef2a82c77c Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 19:49:16 +0530 Subject: [PATCH 60/75] fix(master-binding): one family when two identifiers name one set, and the listing has three names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Two identifiers on the identical holder set are one family.** A duplicated party carrying both a registration code and a phone number on every one of its ledgers produced two skipped families by count and one by content, so a union that was known got hedged. Skipped families are deduplicated by **content** now, not by the identifier that reached them: two identifiers are the same family when they name the same masters, however they were spelled. Mutation-checked — counting occurrences fails the twinned case. **The ADR named one boundary's field as though it were universal.** The table was headed `candidate_listing`, which is the desktop DTO's name; the MCP result emits the same four words as `listing`, and the core reaches them through `Candidates::listing()`. An MCP consumer following the contract would have gone looking for a field that does not exist. The table is keyed on the state itself now, with the three names given above it — and the irony is recorded because it is the lesson: the paragraph explaining that the state is named differently at each boundary was written directly above a table that used one of those names unqualified. Also corrects a stale claim two sections down that the desktop DTO "stays flat". It was flat until this PR, and the defence offered for flattening — "a projection with a tested consumer" — is now recorded as wrong in an instructive way: the tests were real and tested the wrong thing, because the DTO could not express the distinction they would have had to make. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 30 +++++++++++++------ .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +-- .../bridge-tally-core/src/master_binding.rs | 26 ++++++++++++---- .../src/master_binding_tests.rs | 28 +++++++++++++++++ 5 files changed, 73 insertions(+), 17 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index e3d8cee7..ec144e25 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -319,7 +319,15 @@ name reaching a family it cannot separate is withheld under `NoDiscriminatingCandidate`. A consumer that keys on the reason misses the first, which is exactly the defect the preparation screen shipped with. -| `candidate_listing` | what empty means | which `reason` | +**The state has three names, one per boundary.** The core enum is reachable as +`Candidates::listing()`; the **MCP** result carries it as `listing`; the +**desktop** DTO carries it as `candidate_listing`. The four words are identical +everywhere — `none`, `listed`, `truncated`, `withheld` — so the table below is +keyed on the word, and each consumer reads it from the field its own boundary +emits. Naming one boundary's field as though it were universal is how the last +version of this table sent a consumer looking for something that does not exist. + +| listing state | what empty means | which `reason` | | --- | --- | --- | | `none` | no master resembles this name at all | `NoCandidate` | | `withheld` | **many exist**, the binder declined to print an arbitrary slice, and `candidate_count` says how many | either `NoDiscriminatingCandidate` (a name reaching a family) or `IdentifierConflict` (an identifier held by a family) | @@ -331,8 +339,8 @@ total: two or more skipped identifier families, or one beside masters the name reached. One skipped family alone is a single set, and its size is its length. So `candidates.is_empty()` alone answers nothing. The disambiguators are -`reason`, `candidate_count`, `candidate_count_is_lower_bound` and -`candidate_listing`, and a consumer that +`reason`, `candidate_count`, `candidate_count_is_lower_bound` and the listing +state under whichever of its three names the boundary emits, and a consumer that reads the empty vector as "nothing exists" is wrong in two cases out of three. This is stated here, in the producer's contract, rather than left to each @@ -364,12 +372,16 @@ for it measured its own cost at about thirty lines and reported that the change made its code better rather than merely compatible — a hand-assembled disjunction became an exhaustive match. -**The fix stops at the crate boundary, and says so.** The MCP result carries an -explicit `listing` discriminator, because a model is precisely the caller that -would read an empty array as "no such ledger exists". The desktop DTO stays -flat: its screen already distinguishes the three cases and is tested on each, so -flattening there is a projection with a tested consumer rather than an -ambiguity. Neither boundary has the compiler behind it — this protects Rust +**The fix stops at the crate boundary, and says so.** Both projections now carry +an explicit discriminator — `listing` on the MCP result, `candidate_listing` on +the desktop DTO — because a model is precisely the caller that would read an +empty array as "no such ledger exists", and the preparation screen turned out to +be another. The desktop DTO was flat until it was not: it carried a +`candidates_truncated` boolean, which is `is_incomplete()` and so could not tell +a withheld family from an exhausted budget. That flattening was defended here as +"a projection with a tested consumer"; the tests were real and tested the wrong +thing, because the DTO could not express the distinction they would have had to +make. Neither boundary has the compiler behind it — this protects Rust consumers, and the projections are the two places where that protection ends. ### 5. Status vocabulary diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 4596e1ab..6fbea6bd 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "d8718c1227c6555bdd48042a6df66b76c389aef7c7ade73430b02cbb9f7f602c", + "compatibility_surface_sha256": "36dec931402f2e720a7cae33274a22b0f246af598641149c51e226c84e025239", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index b7f0d19f..e488adbc 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "3922e05cc1423ac0d1656a3775354ee00d87e4554659bd95c33435eaa7523499" + "sha256": "a32c2c458f5a359deb5e8ea6d45c6a959d35574ffa6cc260ae7f698b8d12acee" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "d8718c1227c6555bdd48042a6df66b76c389aef7c7ade73430b02cbb9f7f602c" + "manifest_sha256": "36dec931402f2e720a7cae33274a22b0f246af598641149c51e226c84e025239" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index b209594e..51c38920 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1074,10 +1074,15 @@ fn bind_one( // The holders of the largest skipped family, kept by reference so the count // below can ask which listed masters are *not* in it. Nothing is cloned. let mut largest_withheld: Option<&Vec> = None; - // How many families were skipped, which is what decides whether the count - // can be exact: one family's union with the listed masters is computed - // below, two disjoint ones cannot be without materializing them. - let mut skipped_families = 0_usize; + // The skipped families themselves, by reference. Their *count* is not the + // question — two identifiers can be held by the identical set of masters, + // and an entity carrying both a registration code and a phone number that + // appear on exactly the same ledgers has one family, not two. Deduplicated + // below, because counting occurrences hedged a union that was known. + // + // Bounded by `MAX_IDENTIFIERS_PER_NAME`, and only populated when something + // was skipped, which is rare. + let mut skipped: Vec<&Vec> = Vec::new(); for identifier in &entity.identifiers { if let Some(holders) = catalog.by_identifier.get(identifier) { // An identifier held by more masters than a candidate list may show @@ -1089,7 +1094,7 @@ fn bind_one( // before the candidate memo is even consulted. if holders.len() > MAX_CANDIDATES_PER_ENTITY { identifier_conflict = true; - skipped_families += 1; + skipped.push(holders); if holders.len() > withheld_holders { withheld_holders = holders.len(); largest_withheld = Some(holders); @@ -1140,6 +1145,17 @@ fn bind_one( // would be worse than under-counting here: two identifiers can be held by // overlapping families, so summing their sizes would state a number of // masters that do not exist. + // Distinct *sets*, not occurrences. Compared by content rather than by the + // identifier that reached them: two identifiers are the same family when + // they name the same masters, however they were spelled. + let mut distinct_skipped: Vec<&Vec> = Vec::new(); + for family in &skipped { + if !distinct_skipped.contains(family) { + distinct_skipped.push(family); + } + } + let skipped_families = distinct_skipped.len(); + let withheld_holders = match largest_withheld { Some(family) => { // `by_identifier` is filled by pushing entry indices in ascending diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index d40b63af..1ed512c5 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -2925,6 +2925,34 @@ fn an_exactly_counted_family_is_not_reported_as_a_floor() { candidates.count_is_lower_bound(), "masters reached by name may or may not sit inside the skipped family" ); + + // Two identifiers on the **same** masters are one family, not two. A + // duplicated party carrying both a code and a number on every one of its + // ledgers is the shape: the two holder sets are identical, so their union + // is that set and the total is known. Counting occurrences rather than + // distinct sets hedged it. + let twinned = (0..MAX_CANDIDATES_PER_ENTITY + 5) + .map(|index| format!("Twinned Party {index:03} (5550007777) (5550008888)")) + .collect::>(); + let twinned_catalog = MasterCatalog::new(MasterClass::Ledger, &twinned).expect("valid"); + let entity = + SourceEntity::with_identifier_hints(0, "Zeta Holdings", ["5550007777", "5550008888"]) + .expect("valid"); + let same_set = bound(&twinned_catalog, &[entity]) + .entities() + .first() + .cloned() + .expect("one"); + let candidates = &same_set.unresolved().expect("unbound").candidates; + assert_eq!( + candidates.found(), + MAX_CANDIDATES_PER_ENTITY + 5, + "the two identifiers name one set of masters, so the total is its size" + ); + assert!( + !candidates.count_is_lower_bound(), + "two identifiers on the identical holder set are one family, not two" + ); } #[test] From b64495db276e3255e8014bb4678255ab81d23f9b Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 23:54:54 +0530 Subject: [PATCH 61/75] fix(master-binding): bound nested family proof --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 54 +++++++++++++++++-- .../src/master_binding_tests.rs | 51 ++++++++++++++++++ 4 files changed, 104 insertions(+), 7 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index f3af2025..171ffbbc 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "765cb562db0c41f68375b4a34ab5cd955c82b8ad760d477749073e5a6614346b", + "compatibility_surface_sha256": "e76a89976d7a2599d37287cb23dd66a82fbc21c653a0d5974844c132e5a2c175", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 184a9cd1..f8a6292f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "82fb9f8363a5e87834383c02f3b4ba77375d23d163efd31b0d9bae0af1499f0b" + "sha256": "c3366c05d7ad843b91e3b932a1767a29639d745895411a147d20480b1d4f9032" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "765cb562db0c41f68375b4a34ab5cd955c82b8ad760d477749073e5a6614346b" + "manifest_sha256": "e76a89976d7a2599d37287cb23dd66a82fbc21c653a0d5974844c132e5a2c175" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index cbeed2bb..a7838c58 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -59,6 +59,10 @@ pub const MAX_REPORT_CANDIDATE_BYTES: usize = 256 * 1024; /// Most identifiers one name may carry. Exceeding it is refused, never /// truncated. pub const MAX_IDENTIFIERS_PER_NAME: usize = 32; +/// Maximum holder-membership probes spent proving that skipped identifier +/// families are nested. Exhausting this budget keeps the count a lower bound; +/// it must never turn a large-family check into an unbounded per-entity walk. +const MAX_WITHHELD_FAMILY_PROBES: usize = 256; /// Digits a numeric run needs before it is treated as an identifier. Eight /// excludes a year, a rate, a house number and a masked last-four; a mobile, /// an account number and a customer code all clear it. @@ -1080,7 +1084,7 @@ fn bind_one( let mut large_holder_points_elsewhere = false; // Keep references rather than cloning large holder vectors. Distinct // skipped families are the only source of unknown overlap in this union. - let mut withheld_families: Vec = Vec::new(); + let mut withheld_families: Vec<(&[usize], usize)> = Vec::new(); // The holders of the largest skipped family, kept by reference so the count // below can ask which listed masters are *not* in it. Nothing is cloned. let mut largest_withheld: Option<&Vec> = None; @@ -1100,8 +1104,11 @@ fn bind_one( .get(identifier) .copied() .expect("identifier index has a family id"); - if !withheld_families.contains(&family_id) { - withheld_families.push(family_id); + if !withheld_families + .iter() + .any(|(_, existing_id)| *existing_id == family_id) + { + withheld_families.push((holders.as_slice(), family_id)); } if largest_withheld.is_none_or(|family| holders.len() > family.len()) { largest_withheld = Some(holders); @@ -1134,7 +1141,7 @@ fn bind_one( // retain the larger family as the lower-bound floor and mark the count // uncertain without allocating their union. let largest_withheld = largest_withheld.map(Vec::as_slice); - let withheld_count_is_lower_bound = withheld_families.len() > 1; + let withheld_count_is_lower_bound = withheld_families_are_not_nested(&withheld_families); debug_assert!(withheld_families.len() <= MAX_IDENTIFIERS_PER_NAME); // An identifier shared by two masters, and an entity whose identifiers @@ -1290,6 +1297,39 @@ fn bind_one( } } +/// Returns whether the union of skipped holder families may exceed its largest +/// member. Membership checks are deliberately budgeted: a proof of containment +/// is cheap for the usual small family, while an adversarial 20,000-entry family +/// cannot force a source row to walk every holder. An exhausted proof remains a +/// lower bound, which is the safe direction for an operator-facing count. +fn withheld_families_are_not_nested(families: &[(&[usize], usize)]) -> bool { + let Some((largest, largest_id)) = families.iter().max_by_key(|(holders, _)| holders.len()) + else { + return false; + }; + let mut probes_left = MAX_WITHHELD_FAMILY_PROBES; + for (family, family_id) in families { + if *family_id == *largest_id { + continue; + } + if family.len() > largest.len() { + return true; + } + for holder in *family { + if probes_left == 0 { + return true; + } + probes_left -= 1; + #[cfg(test)] + WITHHELD_FAMILY_PROBES.with(|count| count.set(count.get() + 1)); + if largest.binary_search(holder).is_err() { + return true; + } + } + } + false +} + fn unresolved_status( catalog: &MasterCatalog, entity: &SourceEntity, @@ -1524,6 +1564,12 @@ thread_local! { const { std::cell::Cell::new(0) }; } +#[cfg(test)] +thread_local! { + pub(crate) static WITHHELD_FAMILY_PROBES: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} + /// Produces every defensible master, each labelled with the rule that surfaced /// it. The strongest rule wins where several apply. Nothing here ranks by /// similarity, and nothing here chooses. diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 6781172e..d1e69868 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -1281,6 +1281,57 @@ fn a_withheld_family_still_reports_how_many_share_the_identifier() { ); } +#[test] +fn nested_withheld_identifier_families_have_an_exact_union_count() { + let names = (0..MAX_CANDIDATES_PER_ENTITY + 1) + .map(|index| { + if index < MAX_CANDIDATES_PER_ENTITY { + format!("Party {index:03} (5550007777) (5550008888)") + } else { + format!("Party {index:03} (5550007777)") + } + }) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let source = SourceEntity::with_identifier_hints( + 0, + "Zeta Holdings", + ["5550007777", "5550008888"], + ) + .expect("valid"); + let report = bound(&catalog, &[source]); + let candidates = &report.entities()[0].unresolved().expect("unbound").candidates; + assert_eq!(candidates.found(), MAX_CANDIDATES_PER_ENTITY + 1); + assert!(!candidates.count_is_lower_bound()); +} + +#[test] +fn an_unprovable_large_nested_family_remains_a_lower_bound() { + let names = (0..300) + .map(|index| { + if index < 299 { + format!("Party {index:03} (5550007777) (5550008888)") + } else { + format!("Party {index:03} (5550007777)") + } + }) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let source = SourceEntity::with_identifier_hints( + 0, + "Zeta Holdings", + ["5550007777", "5550008888"], + ) + .expect("valid"); + super::WITHHELD_FAMILY_PROBES.with(|count| count.set(0)); + let report = bound(&catalog, &[source]); + let probes = super::WITHHELD_FAMILY_PROBES.with(std::cell::Cell::get); + let candidates = &report.entities()[0].unresolved().expect("unbound").candidates; + assert_eq!(candidates.found(), 300); + assert!(candidates.count_is_lower_bound()); + assert_eq!(probes, 256, "nested-family proof exceeded its hard budget"); +} + #[test] fn a_report_bounds_its_own_candidate_allocation() { // A per-entity cap does not bound a report: the clones exist the moment it From b856f25bf03370169bbd85abc83b1b46af325991 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 00:10:43 +0530 Subject: [PATCH 62/75] Refuse unqualified folded master bindings --- docs/adr/0016-master-binding-authority.md | 50 ++++++++------- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 +- .../source-draft-capture-bindings.json | 15 +++-- .../bridge-tally-core/src/master_binding.rs | 63 +++++++++---------- .../src/master_binding_tests.rs | 37 +++++------ src-tauri/src/agent_import_tests.rs | 12 ++-- src-tauri/src/source_draft/catalog.rs | 25 +++++--- 8 files changed, 114 insertions(+), 96 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 30e8cc53..d2cb9033 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -89,13 +89,13 @@ colliding pair surfaces in the unbound list where an operator can see it. Failing a whole read to report one collision would block all the work it was performed for. -`MasterClass` is `Ledger` or `StockItem`. Both classes failed in practice and -the identifier rules are identical for both, but **the name fold is not**: -§9.4d measured ledgers, and whether a stock item matches by the same rule was -never sent. So a folded stock-item name may *suggest* and may not resolve — -byte equality is unaffected, since it needs no fold. The class is carried both -so a report cannot be applied to the wrong catalog and because the evidence -behind the two differs. +`MasterClass` is `Ledger` or `StockItem`. The identifier rules are identical +for both. Gateway measurements remain useful for candidate ordering, but a +`MasterCatalog` has no product, release, tier, endpoint, or operator-approval +scope. Therefore every folded name suggests candidates only, for either class; +byte equality is unaffected because it needs no fold. The class is carried so +a report cannot be applied to the wrong catalog and because the evidence behind +the two still differs. ### 2. The identifier is the key; the name is a hint @@ -207,15 +207,14 @@ signals disagree, and the disagreement is reported (`IdentifierNameConflict`) rather than silently settled in the identifier's favour. -### 3. Name matching binds only on an exact or normalized-exact unique hit +### 3. Name matching binds only on byte equality -`Exact` is byte equality with the observed master name. `Normalized` is equality -under **Tally's own rule for when two master names are the same**, and only when -exactly one master shares it. +`Exact` is byte equality with the observed master name. A folded name is a +candidate even when exactly one observed master shares its key. -**There are two folds, and which one may answer is the whole of this section.** +**There are two folds, and neither answers in this generic catalog.** -The resolving fold implements exactly the equivalences +The narrower fold implements exactly the equivalences `TALLY_PROTOCOL_REFERENCE.md` §9.4d measured on **licensed TallyPrime 7.1** — the SKU this writes to — by naming each spelling in a voucher and reading the day book back to see which master it reached: @@ -226,7 +225,12 @@ day book back to see which master it reached: - **space, `-` and `/` are one separator**, in both directions. Everything else is exact on codepoints. The wide fold (`master_identity_key`) -carries more than that and may only offer candidates. +carries more than that and may only offer candidates. The narrow fold is also a +candidate rule here: a gateway observation identifies how one observed gateway +resolved a name, while `MasterCatalog::new(class, names)` carries no product, +release, tier, endpoint, or operator-approval scope that could authorize a +different caller to select that master. A write gate cannot repair a wrong +selection once a caller has copied its returned name. **The two rules that matter are negative, and neither is guessable.** An **en dash** and an **underscore** were sent and *rejected*: they are not separators @@ -250,7 +254,8 @@ Log 7.0 Educational* and this project writes to licensed 7.1. Measuring that SKU directly (§9.4d) found the gateway wider: the reverse hyphen direction, leading whitespace, collapsed runs and slash all match. The fold is symmetric again, one key per side, and the asymmetric index the narrow version needed is gone. On the -mutation book **600 of 995** now bind, against 420 under the narrow fold. +mutation book **600 of 995** names a candidate, against 420 under the narrow +fold. It does not bind in the generic catalog. The lesson is not "measure more". It is that **the scope line of an inherited measurement is part of the measurement**: §9.4b was accurate and its scope was @@ -432,14 +437,13 @@ voucher, creates no master, and dispatches nothing. ## Consequences - `agent_import.rs::master_match` and its private `master_key` are deleted and - `validate_masters` is re-expressed over the crate. `match_state` gains - `normalized` and `identifier` alongside `exact`, `near_miss` and `missing`, - and `exact_live_spelling` now appears only on a bound row. A caller reading - that field on a near-miss was reading a guess. -- The old implementation classified *every* normalized-equal name as a - near-miss, so a request differing from the live ledger only in case, - whitespace, or dash style produced a candidate list instead of an answer. - Those now bind and report the live spelling. + `validate_masters` is re-expressed over the crate. `match_state` reports + `exact`, `identifier`, `near_miss`, or `missing`; folded spellings remain + `near_miss` candidates and `exact_live_spelling` appears only on a bound row. +- `BindingBasis::NormalizedName` remains deserializable for historical records, + but current `bind` calls never emit it. Cached or retained bindings cannot + turn a folded suggestion into a target: selection still requires the explicit + operator apply path and a fresh exact catalog binding. - `build_import_xml` and the approved-post recheck still admit **`exact` only**. The import file carries the name verbatim, so a normalized or identifier bind informs the operator without widening what may be written. This PR does not diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 171ffbbc..b21ae14a 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "e76a89976d7a2599d37287cb23dd66a82fbc21c653a0d5974844c132e5a2c175", + "compatibility_surface_sha256": "b429589520600d2dddd02a337a0e93fcb8023dfe8d6344afac0d1f4ffbd4582d", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index f8a6292f..57f52c7a 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "c3366c05d7ad843b91e3b932a1767a29639d745895411a147d20480b1d4f9032" + "sha256": "c4f72a6b962b74ee2e3cf57273355b03e942ac026fbdefca48fbd524bf619b77" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -583,7 +583,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "9b6fce29bb15eb0f71e48bcd364244b1e16893e13f24050cead465642a640184" + "sha256": "fefb5430fc24f5f63fc4132e117d7a953f3d0cfacff46d92211793622521296f" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "e76a89976d7a2599d37287cb23dd66a82fbc21c653a0d5974844c132e5a2c175" + "manifest_sha256": "b429589520600d2dddd02a337a0e93fcb8023dfe8d6344afac0d1f4ffbd4582d" } \ No newline at end of file diff --git a/scripts/fixtures/source-draft-capture-bindings.json b/scripts/fixtures/source-draft-capture-bindings.json index 6c56c3c5..5ef6aadb 100644 --- a/scripts/fixtures/source-draft-capture-bindings.json +++ b/scripts/fixtures/source-draft-capture-bindings.json @@ -12,15 +12,18 @@ "unbound_reason": null }, { - "bound_basis": "normalized_name", - "bound_target": "WR2 Sales", - "candidate_count": 0, + "bound_basis": null, + "bound_target": null, + "candidate_count": 2, "candidate_count_is_lower_bound": false, - "candidate_listing": "none", - "candidates": [], + "candidate_listing": "listed", + "candidates": [ + "WR2 Sales", + "WR2 XML Café Naïve Ledger 01A01A2F" + ], "entry_position": 2, "row_position": 1, - "unbound_reason": null + "unbound_reason": "master_binding_near_miss" }, { "bound_basis": null, diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index a7838c58..f0dbdfb5 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -291,7 +291,9 @@ pub enum BindingBasis { Identifier, /// Byte equality with the observed master name. ExactName, - /// Equality under the comparison key, unique in the catalog. + /// Historical serialized basis retained for reading older records. New + /// binding results never use a fold as authority without an explicit, + /// scoped operator approval path. NormalizedName, } @@ -427,9 +429,10 @@ pub struct Unresolved { /// acting on a binding re-reads and revalidates through the admission path /// that owns identity; nothing here is a lease on the book. /// - **Not that the name may be written as given.** Only `ExactName` is byte -/// equality. A `NormalizedName` or `Identifier` bind means the payload and -/// the live name *differ*, and Bridge's write gate admits `exact` only — use -/// `catalog_name`, not what was requested. +/// equality. `NormalizedName` is retained only to deserialize historical +/// records; current folded names are candidates. An `Identifier` bind means +/// the payload and live name can differ, and Bridge's write gate admits +/// `exact` only — use `catalog_name`, not what was requested. /// - **Not that this is the right master in business terms.** It establishes /// that one deterministic rule selected one master uniquely. Whether that /// party is the one the document meant is a judgement the rules cannot make. @@ -676,9 +679,10 @@ impl FallbackBinding { pub struct SourceEntity { position: usize, name: String, - /// The wide fold. Suggests; never resolves. + /// The wide fold. Suggests candidates; never decides a binding. key: String, - /// The narrow fold. Resolves. + /// The observed gateway fold. In this unscoped catalog it also only + /// suggests candidates; authority requires an explicit scoped path. binding_key: String, identifiers: Vec, } @@ -1217,27 +1221,18 @@ fn bind_one( basis: BindingBasis::Identifier, } } else { - // The narrow index, not the wide one: only a transformation Tally was - // measured performing may settle which master was meant. Everything the - // wide fold reaches and this does not falls through to `collect_candidates` - // below, where it is offered as `NormalizedEqual` for a human to confirm. + // This catalog carries no scope-qualified authority for a fold. A + // gateway observation can inform a candidate search, but cannot make a + // name authoritative for a different observed product/tier/scope. + // Exact names and identifiers above remain decisive; every folded name + // reaches the existing candidate path for a human to confirm. match catalog .by_binding_key .get(&entity.binding_key) .map(Vec::as_slice) { - // §9.4d measured **ledgers**. Whether stock items match by the - // same rule is not merely unmeasured, it was never sent — so a - // folded stock-item name may suggest and may not resolve. Byte - // equality is unaffected: it needs no fold and is checked above. - Some([index]) if catalog.class == MasterClass::Ledger => BindingStatus::Bound { - catalog_name: catalog.entries[*index].name.clone(), - basis: BindingBasis::NormalizedName, - }, - // A single folded match that the class does not license is not an - // ambiguity — nothing shares its key. `NameAmbiguous` would tell a - // consumer that several masters collided when exactly one did not - // qualify, which is a different fact with a different remedy. + // A single candidate is not an ambiguity — exactly one master was + // found, but the catalog cannot prove the fold names it. Some([_]) => unresolved_status( catalog, entity, @@ -1747,8 +1742,8 @@ fn candidate_order( /// against, so trimming it would let `Bank ` claim an exact match on `Bank` /// while the import file still carries the trailing space. The comparison key /// collapses surrounding whitespace anyway, so the two still meet as a -/// normalized match — which is a bind the write gate does not admit, and that -/// is the correct, loud outcome. +/// normalized candidate. It can help an operator find the observed spelling, +/// but no generic catalog is authorized to select it. fn validated_name(value: &str) -> Result { validate_name_bounds(value)?; Ok(value.to_string()) @@ -1801,12 +1796,12 @@ pub(crate) fn comparison_key(value: &str) -> String { /// The **wide** fold: which masters are worth showing a human. /// /// This is deliberately looser than anything measured, and it may never decide -/// a binding. `verified_fold` does that. The separation is the whole design: -/// §9.4b verified three transformations and marks the rest UNVERIFIED, and its -/// own remedy is that a looser fold may *suggest* while only the measured ones -/// resolve. So the reverse hyphen direction, collapsed whitespace runs, leading -/// whitespace and the Unicode dash variants all live here, where the worst they -/// can do is put the right master in front of an operator. +/// a binding. The observed gateway fold is narrower, but this catalog has no +/// product, release, tier, endpoint, or operator-approval scope to treat that +/// observation as selection authority. Both folds therefore only suggest +/// candidates here. So the reverse hyphen direction, collapsed whitespace +/// runs, leading whitespace and the Unicode dash variants all live here, where +/// the worst they can do is put the right master in front of an operator. /// /// An earlier version of this module let this fold bind. It read naturally and /// was wrong: `X - Y` is a common ledger convention — six of seventeen @@ -1830,8 +1825,12 @@ fn master_identity_key(value: &str) -> String { .join(" ") } -/// The fold that may **resolve** a name to a master: exactly the equivalences -/// `TALLY_PROTOCOL_REFERENCE.md` §9.4d measured on the SKU this writes to. +/// The fold observed at one gateway: exactly the equivalences +/// `TALLY_PROTOCOL_REFERENCE.md` §9.4d measured on that observed SKU. +/// +/// It is retained for deterministic candidate ordering. It cannot resolve a +/// name through `MasterCatalog`, whose constructor receives neither that +/// gateway's scope nor explicit operator approval. /// /// §9.4b measured Edit Log 7.0 Educational and marked most of this UNVERIFIED, /// so an earlier version of this module resolved on three transformations only diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index d1e69868..f655e4c3 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -250,16 +250,14 @@ fn an_exact_name_binds() { } #[test] -fn case_binds_but_an_unverified_fold_only_suggests() { - // ASCII case folding is measured, so it resolves. +fn every_fold_is_a_candidate_in_an_unscoped_catalog() { + // ASCII case folding was observed at one gateway, but the generic catalog + // cannot turn that observation into authority. let cased = ledgers(&["Alpha Traders", "Beta Supply"]); - assert_eq!( - bind_one_name(&cased, "ALPHA traders").status, - BindingStatus::Bound { - catalog_name: "Alpha Traders".to_string(), - basis: BindingBasis::NormalizedName, - } - ); + let folded = bind_one_name(&cased, "ALPHA traders"); + assert_eq!(folded.bound_name(), None); + assert_eq!(reason(&folded), UnboundReason::NearMiss); + assert_eq!(candidate_names(&folded), ["Alpha Traders"]); // An en dash, a collapsed whitespace run and leading whitespace are all on // §9.4b's unverified list. The wide fold still reaches the master, so it is @@ -644,17 +642,13 @@ fn the_master_fold_stops_where_tally_stops() { #[test] fn a_trailing_space_never_claims_byte_equality() { // `Bank ` against live `Bank` must not report exact: the import file would - // still carry the trailing space. Normalized is the correct, loud outcome — - // the write gate refuses it. + // still carry the trailing space. The generic catalog has no fold authority, + // so the observed spelling is offered for an operator to select. let catalog = ledgers(&["Bank"]); let binding = bind_one_name(&catalog, "Bank "); - assert_eq!( - binding.status, - BindingStatus::Bound { - catalog_name: "Bank".to_string(), - basis: BindingBasis::NormalizedName, - } - ); + assert_eq!(binding.bound_name(), None); + assert_eq!(reason(&binding), UnboundReason::NearMiss); + assert_eq!(candidate_names(&binding), ["Bank"]); assert_eq!( binding.source_name, "Bank ", "the requested value is echoed verbatim" @@ -2108,6 +2102,13 @@ fn a_bound_status_serializes_without_a_score_field() { assert!(json.get("confidence").is_none()); } +#[test] +fn historical_normalized_basis_remains_deserializable() { + let basis: BindingBasis = serde_json::from_str("\"normalized_name\"") + .expect("older retained binding basis remains readable"); + assert_eq!(basis, BindingBasis::NormalizedName); +} + // --------------------------------------------------------------------------- // Characterization against a realistically shaped book // diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index 9a5eba6d..79e74e27 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -278,15 +278,15 @@ fn schema_balance_matcher_rendering_and_ledger_append_are_fail_closed() { validate_payload(&unbalanced), Err("voucher_not_balanced".to_string()) ); - // ASCII case and one trailing space are transformations - // `TALLY_PROTOCOL_REFERENCE.md` §9.4b measured Tally performing, so they - // name the same live ledger: they bind and report its exact spelling. Only - // byte equality is `exact`, which is what build_import_xml admits. + // The generic agent catalogue carries no scope-qualified fold authority. + // It may offer the observed spelling, but only byte equality is `exact`. for wanted in ["bank ", "bank", "BANK"] { let matched = one_master_match(wanted, &["Bank"]); - assert_eq!(matched["match_state"], "normalized"); + assert_eq!(matched["match_state"], "near_miss"); + assert_eq!(matched["reason"], "master_binding_near_miss"); + assert!(matched.get("exact_live_spelling").is_none()); assert_eq!( - matched["exact_live_spelling"][super::super::PARTY_NAME_MARKER], + matched["candidates"][0]["name"][super::super::PARTY_NAME_MARKER], "Bank" ); } diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index 82d9a0a9..ea95c9f7 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -165,7 +165,9 @@ pub(super) struct CatalogApplySnapshot { /// Binds every source entry's observed ledger name against the captured /// catalog. Advisory only: an empty or unusable capture narrows nothing rather /// than failing the read the operator just performed, and every returned name -/// is still revalidated by the apply path before it can become a target. +/// is still revalidated by the apply path before it can become a target. A +/// folded name is a candidate only; this generic catalog has no +/// scope-qualified authority to select it. fn source_entry_bindings( source: &crate::source_draft_xml::ParsedSource, targets: &[String], @@ -720,11 +722,14 @@ mod tests { assert_eq!(state, "complete"); assert_eq!(bindings.len(), 3); - // Case alone does not defeat a bind, and the live spelling is named. + // A scoped gateway observation does not make this generic catalogue + // authoritative: folding can suggest the live spelling, never select it. assert_eq!(bindings[0].row_position, 1); assert_eq!(bindings[0].entry_position, 1); - assert_eq!(bindings[0].bound_target.as_deref(), Some("Alpha Traders")); - assert_eq!(bindings[0].bound_basis, Some(BindingBasis::NormalizedName)); + assert_eq!(bindings[0].bound_target, None); + assert_eq!(bindings[0].bound_basis, None); + assert_eq!(bindings[0].unbound_reason, Some("master_binding_near_miss")); + assert_eq!(bindings[0].candidates, ["Alpha Traders", "GAMMA ALPHA"]); // The number the operator buried in the ledger name decides where the // name offers a wrong candidate. @@ -798,7 +803,8 @@ mod tests { "20260901", // Byte-exact against a captured name. "Cash1", - // Case and separator folding, against a captured name. + // A fold against a captured name still only suggests a candidate: + // this consumer's catalogue does not carry the observation's authority. "wr2-sales-1", // `AND` for `&` is rejected by the gateway, so it must not bind. "Profit AND Loss A/c0", @@ -816,8 +822,13 @@ mod tests { assert_eq!(bindings[0].bound_target.as_deref(), Some("Cash")); assert_eq!(bindings[0].bound_basis, Some(BindingBasis::ExactName)); - assert_eq!(bindings[1].bound_target.as_deref(), Some("WR2 Sales")); - assert_eq!(bindings[1].bound_basis, Some(BindingBasis::NormalizedName)); + assert_eq!(bindings[1].bound_target, None); + assert_eq!(bindings[1].bound_basis, None); + assert_eq!(bindings[1].unbound_reason, Some("master_binding_near_miss")); + assert_eq!( + bindings[1].candidates, + ["WR2 Sales", "WR2 XML Café Naïve Ledger 01A01A2F"] + ); // `&` is not folded — §9.4d sent `AND` for `&` and Tally rejected it — // so this refuses against a real catalogue rather than in theory, and From e291eb3d7caa928e3bc34cc377e90b8ea8921a03 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 00:18:23 +0530 Subject: [PATCH 63/75] fix(master-binding): avoid ordinary family ID work --- docs/adr/0016-master-binding-authority.md | 11 +++-- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 14 +++--- .../src/master_binding_tests.rs | 49 +++++++++++++------ 5 files changed, 54 insertions(+), 26 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 30e8cc53..73a0dfc0 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -333,9 +333,14 @@ version of this table sent a consumer looking for something that does not exist. | `truncated` | the list was cut, by the per-entity cap or by the report's aggregate byte budget | any | `candidate_count` is exact unless `candidate_count_is_lower_bound` says -otherwise, which happens only where an unmaterialized union prevents an exact -total: two or more skipped identifier families, or one beside masters the name -reached. One skipped family alone is a single set, and its size is its length. +otherwise. A skipped identifier family alone is a single set, and its size is +its length. Multiple skipped families are also exact when the binder proves, +within its 256 membership-probe budget, that every smaller family is contained +in the largest. The count is a lower bound when that containment proof finds an +outside member or exhausts its budget. Independently, a skipped identifier +family beside name candidates is a lower bound only when unlisted name +candidates leave their overlap unknown; when every name candidate is +materialized, the binder counts the known members outside the family exactly. So `candidates.is_empty()` alone answers nothing. The disambiguators are `reason`, `candidate_count`, `candidate_count_is_lower_bound` and the listing diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 171ffbbc..4ca08565 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "e76a89976d7a2599d37287cb23dd66a82fbc21c653a0d5974844c132e5a2c175", + "compatibility_surface_sha256": "dd1c2a0db14995e5c2ecedbcb465365f0d09ba3adf478ab532618673b4564115", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index f8a6292f..83b90059 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "c3366c05d7ad843b91e3b932a1767a29639d745895411a147d20480b1d4f9032" + "sha256": "daa8640b0cd90799d108591f723481063794dfd6bc44d17b8b371601fb433284" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "e76a89976d7a2599d37287cb23dd66a82fbc21c653a0d5974844c132e5a2c175" + "manifest_sha256": "dd1c2a0db14995e5c2ecedbcb465365f0d09ba3adf478ab532618673b4564115" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index a7838c58..17a1eaff 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -850,14 +850,16 @@ impl MasterCatalog { "identifier holder lists are built in entry order" ); - // Assign equal holder sets one family id once, while the catalog is - // being built. Binding then compares these small ids rather than - // walking a potentially huge withheld holder vector for every source - // entity. The sort is a one-time construction cost and compares the - // already-built index values exactly, so a hash collision cannot make - // two different families look equal. + // Assign equal *withheld* holder sets one family id once, while the + // catalog is being built. Only a set larger than a candidate list can + // be withheld, and binding never asks a smaller set for a family id; + // omitting them avoids sorting and cloning every ordinary identifier + // in a large catalog. The remaining sort compares already-built index + // values exactly, so a hash collision cannot make two different + // families look equal. let mut family_order = by_identifier .iter() + .filter(|(_, holders)| holders.len() > MAX_CANDIDATES_PER_ENTITY) .map(|(identifier, holders)| (identifier, holders.as_slice())) .collect::>(); family_order.sort_by_key(|(_, holders)| *holders); diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index d1e69868..b25f9a40 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -1281,6 +1281,27 @@ fn a_withheld_family_still_reports_how_many_share_the_identifier() { ); } +#[test] +fn only_withheld_identifier_holder_sets_receive_family_ids() { + let names = (0..MAX_CANDIDATES_PER_ENTITY + 1) + .map(|index| format!("Party {index:03} (5550007777)")) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let shared_identifier = entity("Source (5550007777)") + .identifiers() + .first() + .cloned() + .expect("valid identifier"); + + assert_eq!(catalog.identifier_family_ids.len(), 1); + assert!(catalog + .identifier_family_ids + .contains_key(&shared_identifier)); + assert!(catalog.identifier_family_ids.iter().all(|(identifier, _)| { + catalog.by_identifier[identifier].len() > MAX_CANDIDATES_PER_ENTITY + })); +} + #[test] fn nested_withheld_identifier_families_have_an_exact_union_count() { let names = (0..MAX_CANDIDATES_PER_ENTITY + 1) @@ -1293,14 +1314,14 @@ fn nested_withheld_identifier_families_have_an_exact_union_count() { }) .collect::>(); let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); - let source = SourceEntity::with_identifier_hints( - 0, - "Zeta Holdings", - ["5550007777", "5550008888"], - ) - .expect("valid"); + let source = + SourceEntity::with_identifier_hints(0, "Zeta Holdings", ["5550007777", "5550008888"]) + .expect("valid"); let report = bound(&catalog, &[source]); - let candidates = &report.entities()[0].unresolved().expect("unbound").candidates; + let candidates = &report.entities()[0] + .unresolved() + .expect("unbound") + .candidates; assert_eq!(candidates.found(), MAX_CANDIDATES_PER_ENTITY + 1); assert!(!candidates.count_is_lower_bound()); } @@ -1317,16 +1338,16 @@ fn an_unprovable_large_nested_family_remains_a_lower_bound() { }) .collect::>(); let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); - let source = SourceEntity::with_identifier_hints( - 0, - "Zeta Holdings", - ["5550007777", "5550008888"], - ) - .expect("valid"); + let source = + SourceEntity::with_identifier_hints(0, "Zeta Holdings", ["5550007777", "5550008888"]) + .expect("valid"); super::WITHHELD_FAMILY_PROBES.with(|count| count.set(0)); let report = bound(&catalog, &[source]); let probes = super::WITHHELD_FAMILY_PROBES.with(std::cell::Cell::get); - let candidates = &report.entities()[0].unresolved().expect("unbound").candidates; + let candidates = &report.entities()[0] + .unresolved() + .expect("unbound") + .candidates; assert_eq!(candidates.found(), 300); assert!(candidates.count_is_lower_bound()); assert_eq!(probes, 256, "nested-family proof exceeded its hard budget"); From 0b3b9e3fa09b80bc93efdaffd95fa6df26706fe8 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 00:19:36 +0530 Subject: [PATCH 64/75] Document folded binding scope limits --- docs/adr/0016-master-binding-authority.md | 44 +++++++++---------- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 43 +++++++----------- 4 files changed, 40 insertions(+), 53 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index d2cb9033..d0895c2a 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -214,23 +214,20 @@ candidate even when exactly one observed master shares its key. **There are two folds, and neither answers in this generic catalog.** -The narrower fold implements exactly the equivalences -`TALLY_PROTOCOL_REFERENCE.md` §9.4d measured on **licensed TallyPrime 7.1** — -the SKU this writes to — by naming each spelling in a voucher and reading the -day book back to see which master it reached: - -- ASCII case folds; -- leading and trailing whitespace is ignored; -- an internal run of spaces collapses; -- **space, `-` and `/` are one separator**, in both directions. - -Everything else is exact on codepoints. The wide fold (`master_identity_key`) -carries more than that and may only offer candidates. The narrow fold is also a -candidate rule here: a gateway observation identifies how one observed gateway -resolved a name, while `MasterCatalog::new(class, names)` carries no product, -release, tier, endpoint, or operator-approval scope that could authorize a -different caller to select that master. A write gate cannot repair a wrong -selection once a caller has copied its returned name. +The narrower fold is a **historical candidate index**, not a current statement +of qualified gateway equivalence. It uses the transformations a previous +implementation treated as equivalent: case, boundary and repeated spaces, and +ASCII space, `-`, and `/`. That index may be broader than the qualified +measurements, so it can only order candidate suggestions. The supporting +observation was scoped to Silver; it measured a slash in the source reaching a +space in the master, but did not measure the reverse direction. It does not +establish a generic, symmetric separator rule. + +The wide fold (`master_identity_key`) carries more still and also only offers +candidates. `MasterCatalog::new(class, names)` carries no product, release, +tier, endpoint, or operator-approval scope that could authorize a caller to +select a folded match. A write gate cannot repair a wrong selection once a +caller has copied its returned name. **The two rules that matter are negative, and neither is guessable.** An **en dash** and an **underscore** were sent and *rejected*: they are not separators @@ -249,13 +246,12 @@ authority. That was corrected by narrowing to the three §9.4b had measured, which cost 420 of 995 mutation binds and withdrew `X - Y`, a common ledger convention. -Then the narrowing turned out to be over-strict, because §9.4b's scope is *Edit -Log 7.0 Educational* and this project writes to licensed 7.1. Measuring that SKU -directly (§9.4d) found the gateway wider: the reverse hyphen direction, leading -whitespace, collapsed runs and slash all match. The fold is symmetric again, one -key per side, and the asymmetric index the narrow version needed is gone. On the -mutation book **600 of 995** names a candidate, against 420 under the narrow -fold. It does not bind in the generic catalog. +Then a later historical interpretation treated the index as a wider licensed +7.1 rule and bound **600 of 995** mutation names, against 420 under the narrow +fold. That was a prior binding result, not a measured candidate count, and is +withdrawn as authority: its scope and directional support were overstated. The +index remains only to make the same possible masters visible to an operator; it +does not bind in the generic catalog. The lesson is not "measure more". It is that **the scope line of an inherited measurement is part of the measurement**: §9.4b was accurate and its scope was diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index b21ae14a..79d2f1b4 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "b429589520600d2dddd02a337a0e93fcb8023dfe8d6344afac0d1f4ffbd4582d", + "compatibility_surface_sha256": "91bae95ca03087161027de4d7c53e0a4f31988ffc85d68c47da4c93c36f6e283", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 57f52c7a..e8eddfd3 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "c4f72a6b962b74ee2e3cf57273355b03e942ac026fbdefca48fbd524bf619b77" + "sha256": "1532925433807ab044f687862680da588bd73c75441bf6bb2c4ba12f75e04935" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "b429589520600d2dddd02a337a0e93fcb8023dfe8d6344afac0d1f4ffbd4582d" + "manifest_sha256": "91bae95ca03087161027de4d7c53e0a4f31988ffc85d68c47da4c93c36f6e283" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index f0dbdfb5..e2101a85 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1825,24 +1825,15 @@ fn master_identity_key(value: &str) -> String { .join(" ") } -/// The fold observed at one gateway: exactly the equivalences -/// `TALLY_PROTOCOL_REFERENCE.md` §9.4d measured on that observed SKU. +/// A historical candidate index, retained for deterministic ordering. /// -/// It is retained for deterministic candidate ordering. It cannot resolve a -/// name through `MasterCatalog`, whose constructor receives neither that -/// gateway's scope nor explicit operator approval. -/// -/// §9.4b measured Edit Log 7.0 Educational and marked most of this UNVERIFIED, -/// so an earlier version of this module resolved on three transformations only -/// and offered the rest as candidates. §9.4d re-ran that measurement on -/// **licensed TallyPrime 7.1**, read the day book back to see which master each -/// name actually reached, and found the gateway wider than the Educational -/// scope allowed anyone to claim: -/// -/// - ASCII case folds; -/// - leading and trailing whitespace is ignored; -/// - an internal run of spaces collapses; -/// - **space, `-` and `/` are one separator**, in both directions. +/// It may be broader than qualified gateway measurements and cannot resolve a +/// name through `MasterCatalog`, whose constructor receives neither a product, +/// release, tier, endpoint, nor explicit operator approval. The historical +/// record was scoped to Silver and measured a slash in the source reaching a +/// space in the master; it did not establish the reverse direction or a +/// generic symmetric separator rule. This key can therefore only suggest a +/// candidate to an operator. /// /// Everything else is exact on codepoints. So the two rules that matter are /// both negative, and neither is guessable from appearance: @@ -1858,8 +1849,8 @@ fn master_identity_key(value: &str) -> String { /// onto a master the gateway keeps apart. It reads like decoding rather than /// folding, which is how it survived two audits of this function. /// -/// Both hyphen directions are measured now, so this is symmetric and one key -/// per side is enough — the asymmetric index an earlier version needed is gone. +/// The implementation remains symmetric solely for candidate discovery. That +/// convenience does not claim symmetric gateway behavior. fn verified_fold(value: &str) -> String { value .chars() @@ -1934,12 +1925,12 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro } else if token.chars().any(char::is_alphanumeric) { previous_was_mask = false; } - // Only the separators §9.4d measured may be discarded. Filtering to - // alphanumerics dropped **every** ASCII punctuation mark, so + // This historical parser discards only `-` and `/`, rather than every + // ASCII punctuation mark. It is not a claim of gateway equivalence. + // Filtering to alphanumerics dropped **every** ASCII punctuation mark, so // `AB_123456` and `AB-123456` canonicalized alike and one identifier - // bound the other's master — while §9.4d had sent an underscore and - // watched Tally *reject* it. The evidence for this fold is one - // measurement about hyphens and slashes; everything else stays content. + // bound the other's master; underscore remains content rather than + // joining the historical candidate normalization. let canonical = token .chars() .filter(|character| !matches!(character, '-' | '/')) @@ -1977,8 +1968,8 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro && digits >= MIN_CODE_IDENTIFIER_DIGITS && letters >= 2 && !foreign_content - // A separator works in both directions, so the date guard has to - // run on both spellings. Removing `-` can *reveal* a date — + // This parser removes separators in either spelling, so the date + // guard has to run on both spellings. Removing `-` can *reveal* a date — // `2025-09-11` becomes `20250911` — and it can just as easily // *hide* two: `DATED20250911-20250912` fuses into one sixteen-digit // run that reads as no date at all, and `is_period` does not see it From b81057eabc2683102ab7a9799b5354b07a25ad00 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 00:26:17 +0530 Subject: [PATCH 65/75] test(master-binding): exercise singleton family admission --- .../crates/bridge-tally-core/src/master_binding_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index b25f9a40..4aebf535 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -1284,7 +1284,7 @@ fn a_withheld_family_still_reports_how_many_share_the_identifier() { #[test] fn only_withheld_identifier_holder_sets_receive_family_ids() { let names = (0..MAX_CANDIDATES_PER_ENTITY + 1) - .map(|index| format!("Party {index:03} (5550007777)")) + .map(|index| format!("Party {index:03} (5550007777) (4455{index:06})")) .collect::>(); let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); let shared_identifier = entity("Source (5550007777)") @@ -1293,6 +1293,10 @@ fn only_withheld_identifier_holder_sets_receive_family_ids() { .cloned() .expect("valid identifier"); + assert!(catalog + .by_identifier + .values() + .any(|holders| holders.len() == 1)); assert_eq!(catalog.identifier_family_ids.len(), 1); assert!(catalog .identifier_family_ids From a0267c45bccdd5d2098c26f266d8f31f6f5a02c0 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 00:42:20 +0530 Subject: [PATCH 66/75] Clarify withheld candidate guidance and synthetic stress scope --- docs/agent/README.md | 18 ++++++++++-------- scripts/source-draft-screen.test.tsx | 11 ++++------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/docs/agent/README.md b/docs/agent/README.md index 1f35a4e6..2f4b144b 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -212,14 +212,16 @@ licence mode, or manually imported file, and only an unnumbered single-voucher `exact_live_spelling` into the payload verbatim; the live name may differ from yours in case, spacing, dash or quote style, and the import file carries whatever you send byte for byte. - - `near_miss` — the row is **not** bound and Bridge chose nothing. Pick from - `candidates`, each labelled with the rule that surfaced it. A single - candidate is still not a decision. Where `reason` is - `master_binding_no_discriminating_candidate`, the name reaches - `candidate_count` possible masters that it does not distinguish and none - is listed. When `candidate_count_is_lower_bound` is true, show this as - "at least N", never an exact total. Use a more complete source name, - or read the ledger list and choose. + - `near_miss` — the row is **not** bound and Bridge chose nothing. Where + `listing` is `withheld`, `candidates` is empty: there is no listed name to + pick. This includes `master_binding_no_discriminating_candidate` and + `master_binding_identifier_conflict`. Obtain a more complete source name + for an indistinguishable name family; conflicting identifiers require + correction of the source identity or explicit operator selection against + the observed ledger list. A fuller name alone does not settle conflicting + identifiers. Where candidates are listed, each carries its comparison + rule; even a single candidate still requires a decision. For every reason, + render `candidate_count_is_lower_bound` as "at least N", never an exact total. - `missing` — no live ledger matched. Bridge never creates masters. 3. Call `build_import_xml` with the payload. It checks exact decimal balance, company date extent, live masters, and local journal integrity, diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index bf07e76b..3eb350a6 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -260,13 +260,10 @@ test("clears saved status when a proposal changes after saving", async () => { root.unmount(); }); -test("groups and lists a catalogue of realistic size without losing the narrowing", async () => { - // The captured catalogue is nine ledgers, which is a real shape but not a - // real size; the fabricated ones here are three. A live company's ledger - // count runs into the thousands, and that is where narrowing earns its place - // — and where a defect in it would be invisible at three targets. Size is - // the one dimension of this control that a capture cannot supply, because - // no lab company has thousands of ledgers. +test("groups and lists a synthetic stress catalogue without losing the narrowing", async () => { + // Synthetic stress bound only: these generated targets exercise narrowing + // and rendering at 2,000 entries. They are not a captured catalogue and do + // not establish a live company's ledger count or production performance. const bulk = Array.from({ length: 2_000 }, (_, index) => `Bulk placeholder ledger ${String(index).padStart(4, "0")}`); const large = { ...catalog, From 0b7d916db1bc0e7e9cb0adbf3f93eef8fbc79359 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 01:23:35 +0530 Subject: [PATCH 67/75] fix: type candidate listing states and preserve unknown-state guidance --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 +- scripts/source-draft-screen.test.tsx | 57 +++++++++++++++++++ src/SourceDraftScreen.tsx | 21 +++++-- src/source-draft-types.ts | 2 +- 5 files changed, 77 insertions(+), 11 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 4ca08565..d7b82db4 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "dd1c2a0db14995e5c2ecedbcb465365f0d09ba3adf478ab532618673b4564115", + "compatibility_surface_sha256": "6b8fc0d0358d877b1f8ffd73e622f986366407449851f7448f9413bd8593e1ab", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 83b90059..48568226 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -723,7 +723,7 @@ }, { "path": "src/SourceDraftScreen.tsx", - "sha256": "bf4bad949a956ddd780358fa52385f1db4dc1014bb489fdb0089e5c0fa59fcdd" + "sha256": "d7277e188c464b7f1b86b85022df4440b148ff04c782abf7a06de218292c9133" }, { "path": "src/TallyReadinessFlow.tsx", @@ -779,7 +779,7 @@ }, { "path": "src/source-draft-types.ts", - "sha256": "5b56de06c8b70f5bf70d35c1230f9c96e9a5988306bc8dd68e0b9017f2f110d8" + "sha256": "37bb64ec9d35d62d405e48e665f4b67335772ee899c049e387bf8684d7c5cdaa" }, { "path": "src/source-draft.css", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "dd1c2a0db14995e5c2ecedbcb465365f0d09ba3adf478ab532618673b4564115" + "manifest_sha256": "6b8fc0d0358d877b1f8ffd73e622f986366407449851f7448f9413bd8593e1ab" } \ No newline at end of file diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index 3eb350a6..0df491d8 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -809,6 +809,63 @@ test("reports a truncated candidate list truthfully and falls back to the flat c root.unmount(); }); +test("does not turn an empty nonzero candidate list into a budget claim", async () => { + for (const listing of ["none", "listed"] as const) { + const emptyListing = { + ...catalog, + targets: ["Alpha placeholder", "Beta placeholder"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: null, + bound_basis: null, + unbound_reason: "master_binding_near_miss", + candidates: [], + candidate_count: 4, + candidate_count_is_lower_bound: false, + candidate_listing: listing, + }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(emptyListing); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: `empty-${listing}` }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + expect(host.textContent).toContain("Candidate details are unavailable. Choose from the full list of 2."); + expect(host.textContent).not.toContain("ran out of room"); + expect(host.textContent).not.toContain("0 possible"); + root.unmount(); + } +}); + +test("unknown candidate listing values fail safely at runtime", async () => { + const unknownListing = { + ...catalog, + targets: ["Alpha placeholder"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: null, + bound_basis: null, + unbound_reason: "master_binding_near_miss", + candidates: [], + candidate_count: 3, + candidate_count_is_lower_bound: false, + candidate_listing: "future_state" as never, + }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(unknownListing); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "unknown-listing" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + expect(host.textContent).toContain("Candidate details are unavailable. Choose from the full list of 1."); + expect(host.textContent).not.toContain("ran out of room"); + root.unmount(); +}); + test("a family withheld under a different reason is not reported as a full report", async () => { // The second withheld shape. An identifier held by more masters than a // candidate list may show is withheld under `identifier_conflict`, not under diff --git a/src/SourceDraftScreen.tsx b/src/SourceDraftScreen.tsx index 5f5211ac..648635cf 100644 --- a/src/SourceDraftScreen.tsx +++ b/src/SourceDraftScreen.tsx @@ -182,13 +182,22 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: // about; a family withheld under `identifier_conflict` reached the budget // sentence and told the operator the report had run out of room when it // had not. - if (binding.candidate_listing === "withheld") { - return `${catalogRefusalLead(binding.unbound_reason)} This source line matches ${count} existing ledgers and tells them apart from none of them, so none is listed. Use a fuller source name, or choose from the full list of ${total}.`; + switch (binding.candidate_listing) { + case "withheld": + return `${catalogRefusalLead(binding.unbound_reason)} This source line matches ${count} existing ledgers and tells them apart from none of them, so none is listed. Use a fuller source name, or choose from the full list of ${total}.`; + case "truncated": + return `${catalogRefusalLead(binding.unbound_reason)} ${count} existing ledgers are involved, but this report ran out of room to list them. Choose from the full list of ${total}.`; + case "none": + case "listed": + return `${catalogRefusalLead(binding.unbound_reason)} Candidate details are unavailable. Choose from the full list of ${total}.`; + default: { + // Keep an unknown wire value safe at runtime, while a new typed state + // requires an explicit case here before the frontend can compile. + const unexpected: never = binding.candidate_listing; + void unexpected; + return `${catalogRefusalLead(binding.unbound_reason)} Candidate details are unavailable. Choose from the full list of ${total}.`; + } } - // Why it refused survives the listing being dropped. Returning only the - // budget sentence here re-hid the strong disagreement that the branch below - // had just been fixed to show — the same defect, one branch over. - return `${catalogRefusalLead(binding.unbound_reason)} ${count} existing ledgers are involved, but this report ran out of room to list them. Choose from the full list of ${total}.`; } const listed = binding.candidate_listing === "truncated" ? `${shown} of ${count}` : `${shown}`; const lead = catalogRefusalLead(binding.unbound_reason); diff --git a/src/source-draft-types.ts b/src/source-draft-types.ts index b49191e7..2892a090 100644 --- a/src/source-draft-types.ts +++ b/src/source-draft-types.ts @@ -87,7 +87,7 @@ export type SourceDraftCatalogBinding = { /** "none" | "listed" | "truncated" | "withheld" — the core's own word for * this state, carried rather than inferred: an empty list beside a nonzero * count is a withheld family or an exhausted budget, and they differ. */ - candidate_listing: string; + candidate_listing: "none" | "listed" | "truncated" | "withheld"; }; export type SourceDraftCatalogTargets = { From ee220a687c6d14de2db0e8419c276b26ff0732c7 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 01:29:01 +0530 Subject: [PATCH 68/75] fix(binding): keep folded matches candidate-only --- docs/tally/TALLY_PROTOCOL_REFERENCE.md | 8 +++++ docs/tally/TEST_CORPUS.md | 11 +++--- .../source-draft-capture-bindings.json | 5 +++ scripts/source-draft-screen.test.tsx | 6 ++-- .../bridge-tally-core/src/master_binding.rs | 35 +++++++++++++------ .../src/master_binding_tests.rs | 33 +++++++++++++++-- src-tauri/src/agent_import.rs | 3 +- src-tauri/src/source_draft/catalog.rs | 14 ++++---- 8 files changed, 85 insertions(+), 30 deletions(-) diff --git a/docs/tally/TALLY_PROTOCOL_REFERENCE.md b/docs/tally/TALLY_PROTOCOL_REFERENCE.md index 56fed873..e61ccd91 100644 --- a/docs/tally/TALLY_PROTOCOL_REFERENCE.md +++ b/docs/tally/TALLY_PROTOCOL_REFERENCE.md @@ -1267,6 +1267,14 @@ assuming something nobody has measured. ### 9.4d Master-name matching on **licensed** TallyPrime 7.1 +**Superseded as generic binding authority, 2026-09-12.** The observations below remain +an exact record for their one licensed 7.1 instance, company, ledger class and import-time +operation. They do not authorize a scope-free `MasterCatalog` to bind a folded spelling: +that constructor carries none of the product, release, endpoint or approval information the +measurement requires. Generic binding therefore presents every folded result as a candidate +and requires operator selection plus exact revalidation; it must not treat these directional +observations as a symmetric, portable canonicalization rule. + **VERIFIED 2026-09-12**, and it widens §9.4b rather than confirming it. §9.4b is inherited from a 2026-07-30 measurement on **Edit Log 7.0 Educational** and marks licensed TallyPrime UNVERIFIED. This is that measurement re-run on the SKU this project actually writes to: **TallyPrime 7.1, diff --git a/docs/tally/TEST_CORPUS.md b/docs/tally/TEST_CORPUS.md index 66f63494..d08fd671 100644 --- a/docs/tally/TEST_CORPUS.md +++ b/docs/tally/TEST_CORPUS.md @@ -457,8 +457,9 @@ names**. | `Zeta Nowhere Traders` | `missing`, `listing: "none"` | an absence stated as an absence, not as an empty list | | `MB PARTY BETA` | `near_miss`, 4 candidates | a prefix family surfaced whole | -**`exact_live_spelling` appeared on bound rows only** — `exact`, `identifier`, `normalized` — -and on no refusal. The same ten names sent to the **previously installed** server, same company +**`exact_live_spelling` appeared on bound rows only** — `exact`, `identifier`, and the +historical `normalized` result — and on no refusal. `normalized` is superseded as a generic +binding state: a current scope-free catalog keeps that result candidate-only. The same ten names sent to the **previously installed** server, same company and instance minutes earlier, returned `exact_live_spelling` alongside `match_state: "near_miss"` for two of them. That field on a refusal is a guess wearing the shape of an answer, and it is what this change deletes; the two runs are the before and after on one real instance. @@ -471,9 +472,9 @@ the MCP consumer; the desktop consumer shares the crate but was not driven. **Superseded in part, 2026-09-12.** The separator row below was read as a defect and drove a narrowing of the resolving fold. `TALLY_PROTOCOL_REFERENCE.md` §9.4d then measured that same equivalence directly on **licensed** TallyPrime 7.1 and found Tally does accept it — along with -leading whitespace, collapsed runs and a slash — so the fold was widened back to what the gateway -actually does. Read the paragraph below as the history it is: the row's `normalized` result was -right, and the reasoning that called it wrong was working from §9.4b's Educational scope. +leading whitespace, collapsed runs and a slash — so the capture retained the result as a scoped observation. Read the paragraph below as history: +the row records what that one gateway accepted, but it does not license a generic catalog to +resolve a folded name without an operator's selected target and exact revalidation. **It found a defect, which is the reason to run these.** On the first run the third row *bound* `MB-PILOT-ALPHA-(5550001001)` to a master carrying spaces. That is the **reverse** of the diff --git a/scripts/fixtures/source-draft-capture-bindings.json b/scripts/fixtures/source-draft-capture-bindings.json index 5ef6aadb..b2257a8b 100644 --- a/scripts/fixtures/source-draft-capture-bindings.json +++ b/scripts/fixtures/source-draft-capture-bindings.json @@ -47,6 +47,11 @@ "response_sha256": "f354993704f0feddc27a46d73b4ca10787b6028d9f385d8323994b18e5b34e0f", "state": "complete" }, + "provenance": { + "catalogue": "captured StandardLedgerCatalogV1 response", + "source_xml": "authored test input", + "binding_dto": "derived by the production parser and binder" + }, "source_sha256": "41c4de4324e0eb03c46b92aee7b0be0a0a1ff29feb63ffc3b8435cce559810aa", "targets": [ "Bridge Nested Debtor WR4", diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index 3eb350a6..1a443563 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -600,9 +600,9 @@ test("distinguishes the two other refusals that are not weak matches", async () test("the picker groups a real captured catalogue, not a shape the test invented", async () => { // Every other test here writes both the catalogue and its bindings, so they // show the component agrees with an assumed response. This one reads - // `scripts/fixtures/source-draft-capture-bindings.json`, which is the DTO the - // **producer** emits from a `StandardLedgerCatalogV1` response captured on - // licensed TallyPrime 7.1 — nine real ledger names, including Devanagari, an + // `scripts/fixtures/source-draft-capture-bindings.json`, a DTO derived by the + // producer from a `StandardLedgerCatalogV1` response captured on licensed + // TallyPrime 7.1 and authored source XML — nine real ledger names, including Devanagari, an // `&` name, and an NFD ledger beside NFC ones. // // The Rust test `the_binder_meets_a_real_catalogue_through_the_production_parse` diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index ee2549af..17b2d946 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -291,9 +291,16 @@ pub enum BindingBasis { Identifier, /// Byte equality with the observed master name. ExactName, - /// Historical serialized basis retained for reading older records. New - /// binding results never use a fold as authority without an explicit, - /// scoped operator approval path. +} + +/// Historical wire values retained solely for decoding archived binding records. +/// Current [`BindingStatus::Bound`] uses [`BindingBasis`], which deliberately +/// has no folded variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HistoricalBindingBasis { + Identifier, + ExactName, NormalizedName, } @@ -429,8 +436,8 @@ pub struct Unresolved { /// acting on a binding re-reads and revalidates through the admission path /// that owns identity; nothing here is a lease on the book. /// - **Not that the name may be written as given.** Only `ExactName` is byte -/// equality. `NormalizedName` is retained only to deserialize historical -/// records; current folded names are candidates. An `Identifier` bind means +/// equality. `HistoricalBindingBasis` retains old wire values separately; +/// current folded names are candidates. An `Identifier` bind means /// the payload and live name can differ, and Bridge's write gate admits /// `exact` only — use `catalog_name`, not what was requested. /// - **Not that this is the right master in business terms.** It establishes @@ -1635,12 +1642,12 @@ fn collect_candidates( // token sets and memo keys across the whole module on the strength of it, // and still leave the candidate list assembled from a key that is not the // one the ambiguity was found in. - for index in catalog + let binding_matches = catalog .by_binding_key .get(&entity.binding_key) - .into_iter() - .flatten() - { + .cloned() + .unwrap_or_default(); + for index in &binding_matches { offer(*index, CandidateRule::NormalizedEqual); } if withheld.is_empty() { @@ -1713,7 +1720,15 @@ fn collect_candidates( // // `found` is computed above from the full union, so the count an operator // sees is unaffected by the cap; only the listing is. - listed.sort_by(|left, right| candidate_order(catalog, left, right)); + // The narrower historical index was the reason this near-miss was reached. + // Preserve its candidates before the bounded listing drops wider-only ones; + // this is visibility, never authority or a similarity score. + listed.sort_by(|left, right| { + binding_matches + .contains(&right.0) + .cmp(&binding_matches.contains(&left.0)) + .then_with(|| candidate_order(catalog, left, right)) + }); listed.truncate(MAX_CANDIDATES_PER_ENTITY); (listed, found) } diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 68944dd1..fa638804 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -393,6 +393,32 @@ fn a_single_candidate_still_does_not_bind() { assert_eq!(binding.bound_name(), None); } +#[test] +fn a_narrow_fold_candidate_survives_a_wide_fold_cap() { + // `comparison_key` folds Unicode case while the historical candidate index + // intentionally does not. Twenty-five slash spellings therefore share the + // wide key, while the space spelling is the only narrow-fold candidate. + // The report must retain the latter before applying the listing cap. + let upper = ['Α', 'Β', 'Γ', 'Δ', 'Ε', 'Ζ']; + let lower = ['α', 'β', 'γ', 'δ', 'ε', 'ζ']; + let mut names = vec!["ΑΒΓΔΕ Ζ".to_string()]; + for mask in 1..=MAX_CANDIDATES_PER_ENTITY { + let spelling = upper + .iter() + .zip(lower) + .enumerate() + .map(|(bit, (upper, lower))| if mask & (1 << bit) == 0 { *upper } else { lower }) + .collect::(); + names.push(format!("{}{}", &spelling[..spelling.len() - 'Ζ'.len_utf8()], "/ζ")); + } + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid catalog"); + let binding = bind_one_name(&catalog, "ΑΒΓΔΕ/Ζ"); + let candidates = binding.unresolved().expect("candidate-only fold").candidates.listed(); + assert_eq!(candidates.len(), MAX_CANDIDATES_PER_ENTITY); + assert_eq!(candidates[0].catalog_name, "ΑΒΓΔΕ Ζ"); + assert_eq!(candidates[0].rule, CandidateRule::NormalizedEqual); +} + #[test] fn a_truncated_source_name_surfaces_the_longer_master() { let catalog = ledgers(&["DELTA WHOLESALE PLACEHOLDER", "Beta Supply"]); @@ -2120,10 +2146,11 @@ fn a_bound_status_serializes_without_a_score_field() { } #[test] -fn historical_normalized_basis_remains_deserializable() { - let basis: BindingBasis = serde_json::from_str("\"normalized_name\"") +fn historical_normalized_basis_is_not_a_current_bound_basis() { + assert!(serde_json::from_str::("\"normalized_name\"").is_err()); + let historical: HistoricalBindingBasis = serde_json::from_str("\"normalized_name\"") .expect("older retained binding basis remains readable"); - assert_eq!(basis, BindingBasis::NormalizedName); + assert_eq!(historical, HistoricalBindingBasis::NormalizedName); } // --------------------------------------------------------------------------- diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index 20403ac9..aee16b2c 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -455,7 +455,7 @@ impl Server { payload: json!({"company": company_json(&company, std::slice::from_ref(&company)), "result": { "state":"refused", "reason":"masters_not_exact", "masters":report, "catalogue_evidence_sha256":sha256_json(&catalogue), - "next_step":"Use the exact live spelling from validate_masters, then build a new batch. No file was written." + "next_step":"For each near-miss, an operator must select a candidate, update the payload to that chosen exact live spelling, then run validate_masters again before building a new batch. Do not copy a candidate automatically. No file was written." }}), evidence: accumulated.clone(), company_guid: Some(payload.company_guid), @@ -1644,7 +1644,6 @@ fn master_match_json(binding: &EntityBinding) -> Value { // nothing else. let match_state = match basis { BindingBasis::ExactName => "exact", - BindingBasis::NormalizedName => "normalized", BindingBasis::Identifier => "identifier", }; json!({ diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index ea95c9f7..a9c5d9c7 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -856,12 +856,12 @@ mod tests { "the committed fixture no longer matches what the binder emits" ); - // The evidence fields are the capture's own, not placeholders. They - // come from the retained capture's metadata sidecar and from this - // source document's digest, and are asserted here so the fixture - // cannot quietly go back to zeros while still calling itself a - // capture. `capture_id` has no captured counterpart — it is minted - // locally per read — so it stays a fixed synthetic UUID. + // The evidence fields belong to the retained **catalogue** capture; + // the source XML is authored test input and its digest is only that + // input's identity. The fixture's provenance records this split so it + // cannot be presented as an end-to-end source-draft observation. + // `capture_id` has no captured counterpart — it is minted locally per + // read — so it stays a fixed synthetic UUID. let provenance: serde_json::Value = serde_json::from_str(include_str!( "../../crates/bridge-tally-protocol/tests/fixtures/agent/native-ledger-catalogue.json" )) @@ -881,7 +881,7 @@ mod tests { assert_eq!( committed["source_sha256"], serde_json::Value::String(source.sha256.clone()), - "the fixture no longer carries this source document's digest" + "the fixture no longer carries the authored source input digest" ); } From de9ec4474445eccb3ee1715a4262346445f9072a Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 01:33:59 +0530 Subject: [PATCH 69/75] fix(binding): preserve bounded candidate priority --- docs/tally/TALLY_PROTOCOL_REFERENCE.md | 26 +++++++------ .../bridge-tally-core/src/master_binding.rs | 39 +++++++++---------- .../src/master_binding_tests.rs | 22 +++++++---- 3 files changed, 48 insertions(+), 39 deletions(-) diff --git a/docs/tally/TALLY_PROTOCOL_REFERENCE.md b/docs/tally/TALLY_PROTOCOL_REFERENCE.md index e61ccd91..bde016da 100644 --- a/docs/tally/TALLY_PROTOCOL_REFERENCE.md +++ b/docs/tally/TALLY_PROTOCOL_REFERENCE.md @@ -1339,18 +1339,20 @@ more variants, same method, same readback and deletion: | ` mb-pilot/alpha (5550001001) ` | all five at once | **matched** | | ` mb probe ledger a ` against `MB-PROBE-LEDGER-A` | case + space-for-hyphen + surrounding + run | **matched** | -All eight posted against the intended master, confirmed by day-book readback. **So the folds -compose**, and a canonical form applying every measured transformation before comparing is -licensed by measurement rather than by extrapolation from the single-axis rows. - -**What this says.** On licensed 7.1, Tally treats **space, hyphen and slash** as interchangeable -separators, collapses internal whitespace runs, ignores leading and trailing whitespace, folds -**ASCII** case, and is otherwise **exact on codepoints**. - -> **RULE: separators fold, and the set is `space`, `-`, `/` — nothing else.** An en dash and an -> underscore are ordinary characters to Tally and are **not** separators, so a fold that treats -> "punctuation" or "separators" as a class is wider than the gateway and will merge masters it -> keeps apart. +All eight posted against the intended master, confirmed by day-book readback. + +**Superseded interpretation, 2026-09-12.** The rows above remain the scoped Silver 7.1 +observations. They do **not** license a generic symmetric or canonical separator fold: the +measured slash direction is a slash in the supplied name reaching a space in the live master; +the reverse direction was not sent. A scope-free binder must therefore keep all folded spellings +candidate-only and require operator selection plus exact revalidation. The earlier statements +that `space`, `-`, and `/` are interchangeable, or that a canonical form is licensed, are +withdrawn as binding authority rather than erased from the probe history. + +**What remains measured in this scope.** The listed forward slash-to-space case, the recorded +hyphen and whitespace cases, and the rejected en dash, underscore, abbreviation, suffix, and +NFD cases are observations of this one operation. They do not generalize across product, tier, +object class, direction, or caller. That is the trap §9.4b warned about, arriving from the other side: the danger was never only that a reader would fold too much, it was that "normalises separators" names no particular set. Two of diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 17b2d946..d67025cf 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -293,17 +293,6 @@ pub enum BindingBasis { ExactName, } -/// Historical wire values retained solely for decoding archived binding records. -/// Current [`BindingStatus::Bound`] uses [`BindingBasis`], which deliberately -/// has no folded variant. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum HistoricalBindingBasis { - Identifier, - ExactName, - NormalizedName, -} - /// The masters worth showing, and — in the variant itself — what an absence of /// them means. /// @@ -436,8 +425,9 @@ pub struct Unresolved { /// acting on a binding re-reads and revalidates through the admission path /// that owns identity; nothing here is a lease on the book. /// - **Not that the name may be written as given.** Only `ExactName` is byte -/// equality. `HistoricalBindingBasis` retains old wire values separately; -/// current folded names are candidates. An `Identifier` bind means +/// equality. Binding reports have no core persistence reader, so current +/// `BindingBasis` deliberately rejects historical folded wire values; current +/// folded names are candidates. An `Identifier` bind means /// the payload and live name can differ, and Bridge's write gate admits /// `exact` only — use `catalog_name`, not what was requested. /// - **Not that this is the right master in business terms.** It establishes @@ -1645,9 +1635,9 @@ fn collect_candidates( let binding_matches = catalog .by_binding_key .get(&entity.binding_key) - .cloned() - .unwrap_or_default(); - for index in &binding_matches { + .map(Vec::as_slice) + .unwrap_or(&[]); + for index in binding_matches { offer(*index, CandidateRule::NormalizedEqual); } if withheld.is_empty() { @@ -1724,10 +1714,19 @@ fn collect_candidates( // Preserve its candidates before the bounded listing drops wider-only ones; // this is visibility, never authority or a similarity score. listed.sort_by(|left, right| { - binding_matches - .contains(&right.0) - .cmp(&binding_matches.contains(&left.0)) - .then_with(|| candidate_order(catalog, left, right)) + // Candidate-rule precedence is unchanged: an identifier still leads a + // folded suggestion. Within the same rule, a binary-searchable narrow + // holder gets the bounded slot before a wider-only holder. + left.1 + .rank() + .cmp(&right.1.rank()) + .then_with(|| { + binding_matches + .binary_search(&right.0) + .is_ok() + .cmp(&binding_matches.binary_search(&left.0).is_ok()) + }) + .then_with(|| catalog.entries[left.0].name.cmp(&catalog.entries[right.0].name)) }); listed.truncate(MAX_CANDIDATES_PER_ENTITY); (listed, found) diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index fa638804..dcdc97a5 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -411,12 +411,23 @@ fn a_narrow_fold_candidate_survives_a_wide_fold_cap() { .collect::(); names.push(format!("{}{}", &spelling[..spelling.len() - 'Ζ'.len_utf8()], "/ζ")); } + names.extend([ + "Identifier Alpha (5550001234)".to_string(), + "Identifier Beta (5550001234)".to_string(), + ]); let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid catalog"); - let binding = bind_one_name(&catalog, "ΑΒΓΔΕ/Ζ"); + let source = SourceEntity::with_identifier_hints(0, "ΑΒΓΔΕ/Ζ", ["5550001234"]) + .expect("valid source"); + let binding = bound(&catalog, &[source]).entities()[0].clone(); let candidates = binding.unresolved().expect("candidate-only fold").candidates.listed(); assert_eq!(candidates.len(), MAX_CANDIDATES_PER_ENTITY); - assert_eq!(candidates[0].catalog_name, "ΑΒΓΔΕ Ζ"); - assert_eq!(candidates[0].rule, CandidateRule::NormalizedEqual); + assert_eq!(candidates[0].catalog_name, "Identifier Alpha (5550001234)"); + assert_eq!(candidates[1].catalog_name, "Identifier Beta (5550001234)"); + assert!(candidates[..2] + .iter() + .all(|candidate| candidate.rule == CandidateRule::SharedIdentifier)); + assert_eq!(candidates[2].catalog_name, "ΑΒΓΔΕ Ζ"); + assert_eq!(candidates[2].rule, CandidateRule::NormalizedEqual); } #[test] @@ -2146,11 +2157,8 @@ fn a_bound_status_serializes_without_a_score_field() { } #[test] -fn historical_normalized_basis_is_not_a_current_bound_basis() { +fn historical_normalized_basis_is_rejected_by_current_bindings() { assert!(serde_json::from_str::("\"normalized_name\"").is_err()); - let historical: HistoricalBindingBasis = serde_json::from_str("\"normalized_name\"") - .expect("older retained binding basis remains readable"); - assert_eq!(historical, HistoricalBindingBasis::NormalizedName); } // --------------------------------------------------------------------------- From 9154e99ab21b55ba87b69d3a5b2b3cfed20dccdc Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 01:34:00 +0530 Subject: [PATCH 70/75] chore(compat): reseal binding corrections --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index f3be5b10..16766ecb 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "208008da510439ccd40ca6201ff2b95111995669e984c565f4f059094cf00511", + "compatibility_surface_sha256": "7a0d85f1c5f585b59443d283535bda1c3dd3cba7cdaa89f04df8ba5a0ab957de", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index d5fa0802..f36ee5c3 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -35,7 +35,7 @@ }, { "path": "docs/tally/TALLY_PROTOCOL_REFERENCE.md", - "sha256": "c967120a733f2198100bc9a891ed9bed49e2321c58fdad5e37aebc81bd9bf995" + "sha256": "2a115ee06c8f26caae7f8fb703e709599f3ca01dc87c6484bb0ec88b1ff870dd" }, { "path": "docs/tally/compatibility/README.md", @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "77b1f703d0e85d29563ef2356505dcda7396362553887207c609eee96a470daf" + "sha256": "502d89a9f40f9d4c2bdc7ed66772d7786642a1831d8884824fd6ce19a4b18ddc" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "208008da510439ccd40ca6201ff2b95111995669e984c565f4f059094cf00511" + "manifest_sha256": "7a0d85f1c5f585b59443d283535bda1c3dd3cba7cdaa89f04df8ba5a0ab957de" } \ No newline at end of file From c0236871818344398c227982e4e5326d8b5bcab9 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 01:36:02 +0530 Subject: [PATCH 71/75] docs: state the current binding basis and historical report boundary --- docs/adr/0016-master-binding-authority.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index edddcaaa..d9ddd59f 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -395,7 +395,7 @@ Per entity, exactly one of: | status | meaning | | --- | --- | -| `Bound { catalog_name, basis }` | one master, decided by `Identifier`, `ExactName`, or `NormalizedName` | +| `Bound { catalog_name, basis }` | one master, decided by `Identifier` or `ExactName` | | `Ambiguous { candidates, .. }` | more than one master is defensible, including every identifier conflict | | `Unmatched { candidates, .. }` | no rule produced a candidate | @@ -441,12 +441,13 @@ voucher, creates no master, and dispatches nothing. `validate_masters` is re-expressed over the crate. `match_state` reports `exact`, `identifier`, `near_miss`, or `missing`; folded spellings remain `near_miss` candidates and `exact_live_spelling` appears only on a bound row. -- `BindingBasis::NormalizedName` remains deserializable for historical records, - but current `bind` calls never emit it. Cached or retained bindings cannot - turn a folded suggestion into a target: selection still requires the explicit - operator apply path and a fresh exact catalog binding. +- Current `BindingBasis` cannot construct or deserialize `NormalizedName`. + The core has no binding-report persistence reader; older captured report + JSON remains historical evidence, not a current binding input. Persisted + operator selections still use their existing separate catalogue-binding + representation and require a fresh exact catalogue check before use. - `build_import_xml` and the approved-post recheck still admit **`exact` only**. - The import file carries the name verbatim, so a normalized or identifier bind + The import file carries the name verbatim, so a folded candidate or identifier bind informs the operator without widening what may be written. This PR does not move the write gate. - The MCP result reports an unbound entity's `unresolved_identity` wrapped in From 055b59f6ebf185708a4e82479b9a7868cd851be5 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 01:47:09 +0530 Subject: [PATCH 72/75] fix(ui): guide identifier conflicts from complete catalog --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +-- scripts/source-draft-screen.test.tsx | 4 +-- src/SourceDraftScreen.tsx | 26 ++++++++++++++----- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index d7b82db4..6d2ab6e6 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "6b8fc0d0358d877b1f8ffd73e622f986366407449851f7448f9413bd8593e1ab", + "compatibility_surface_sha256": "6dfba412722a6ad086bbadcd0c97a8fe10cd899da7ee9f7da554953fdbad04b4", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 48568226..e5c3e906 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -723,7 +723,7 @@ }, { "path": "src/SourceDraftScreen.tsx", - "sha256": "d7277e188c464b7f1b86b85022df4440b148ff04c782abf7a06de218292c9133" + "sha256": "18b991c944f6799f81a0c5dbdf58bed5650075923bf369e639db02333a278e8a" }, { "path": "src/TallyReadinessFlow.tsx", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "6b8fc0d0358d877b1f8ffd73e622f986366407449851f7448f9413bd8593e1ab" + "manifest_sha256": "6dfba412722a6ad086bbadcd0c97a8fe10cd899da7ee9f7da554953fdbad04b4" } \ No newline at end of file diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index 0df491d8..9bf17e69 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -896,8 +896,8 @@ test("a family withheld under a different reason is not reported as a full repor await act(async () => button(host, "Load existing ledgers").click()); expect(host.textContent).toContain("matches at least 30 existing ledgers and tells them apart from none of them, so none is listed"); - expect(host.textContent).toContain("The identifiers in this source line do not agree on one existing ledger"); - expect(host.textContent).toContain("either one of them appears in several, or they point at different ones"); + expect(host.textContent).toContain("Review it against the complete observed catalogue and confirm the intended identity before choosing"); + expect(host.textContent).not.toContain("Use a fuller source name"); expect(host.textContent).not.toContain("ran out of room"); root.unmount(); }); diff --git a/src/SourceDraftScreen.tsx b/src/SourceDraftScreen.tsx index 648635cf..2a645160 100644 --- a/src/SourceDraftScreen.tsx +++ b/src/SourceDraftScreen.tsx @@ -173,7 +173,9 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: // — slicing put the right one out of view about a third of the time across // sixteen live catalogues: `TALLY_PROTOCOL_REFERENCE.md` §9.4c states the // rule, `TEST_CORPUS.md` §9.1 carries the counts and their scope — and a - // fuller source name fixes it. A **truncated** listing is this report + // fuller source name can fix that name-family case. An identifier conflict + // requires the complete observed catalogue and intended identity; rewriting + // the name alone cannot resolve it. A **truncated** listing is this report // running out of room on earlier rows; the source name is fine and nothing // the operator writes here would change it. // @@ -183,8 +185,12 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: // sentence and told the operator the report had run out of room when it // had not. switch (binding.candidate_listing) { - case "withheld": - return `${catalogRefusalLead(binding.unbound_reason)} This source line matches ${count} existing ledgers and tells them apart from none of them, so none is listed. Use a fuller source name, or choose from the full list of ${total}.`; + case "withheld": { + const action = isIdentifierConflict(binding.unbound_reason) + ? `Review this source line against the complete observed catalogue and confirm the intended identity before choosing from the full list of ${total}.` + : `Use a fuller source name, or choose from the full list of ${total}.`; + return `${catalogRefusalLead(binding.unbound_reason)} This source line matches ${count} existing ledgers and tells them apart from none of them, so none is listed. ${action}`; + } case "truncated": return `${catalogRefusalLead(binding.unbound_reason)} ${count} existing ledgers are involved, but this report ran out of room to list them. Choose from the full list of ${total}.`; case "none": @@ -204,19 +210,25 @@ function catalogBindingSummary(binding: SourceDraftCatalogBinding | null, total: return `${lead} Nothing is chosen; ${listed} possible ${shown === 1 ? "ledger is" : "ledgers are"} listed first, and the full list of ${total} follows.`; } +function isIdentifierConflict(reason: string | null) { + return reason === "master_binding_identifier_conflict" + || reason === "master_binding_identifier_name_conflict"; +} + /// Why binding refused, where the reason changes what the operator should look /// at. A conflict is not a weak match: both sides of it are strong, and they /// disagree. function catalogRefusalLead(reason: string | null) { switch (reason) { case "master_binding_identifier_name_conflict": - return "This source name matches one existing ledger exactly, while an identifier inside it matches a different one, and they disagree."; + return "This source name matches one existing ledger exactly, while an identifier inside it matches a different one, and they disagree. Review the complete observed catalogue and confirm the intended identity before choosing."; case "master_binding_identifier_conflict": // Two different shapes reach this reason: one identifier carried by // several ledgers, and several identifiers each reaching a different - // ledger. Naming only the first sent the operator hunting for a duplicate - // that does not exist. - return "The identifiers in this source line do not agree on one existing ledger — either one of them appears in several, or they point at different ones."; + // ledger. The operator must compare against the complete observed + // catalogue and confirm the intended identity; rewriting the name alone + // cannot resolve an identifier conflict. + return "The identifiers in this source line do not agree on one existing ledger. Review it against the complete observed catalogue and confirm the intended identity before choosing."; case "master_binding_name_ambiguous": // Not "separators": `TALLY_PROTOCOL_REFERENCE.md` §9.4d measured which // ones fold on the release this writes to — space, hyphen and slash do, From 5dab44e54de8f7066afaea901035299a61be54e9 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 01:50:41 +0530 Subject: [PATCH 73/75] fix(ui): explain truncated identifier conflicts --- docs/agent/README.md | 11 ++++++++--- scripts/source-draft-screen.test.tsx | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/agent/README.md b/docs/agent/README.md index 2f4b144b..af41af45 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -218,9 +218,14 @@ licence mode, or manually imported file, and only an unnumbered single-voucher `master_binding_identifier_conflict`. Obtain a more complete source name for an indistinguishable name family; conflicting identifiers require correction of the source identity or explicit operator selection against - the observed ledger list. A fuller name alone does not settle conflicting - identifiers. Where candidates are listed, each carries its comparison - rule; even a single candidate still requires a decision. For every reason, + the observed ledger list. Identifier-conflict recovery is independent of + `listing`: a `truncated` result may show an outside name candidate while + omitting the whole large identifier family, so choosing only among listed + candidates is insufficient. Inspect the complete observed catalogue and + correct or explicitly confirm the intended source identity; a fuller name + alone does not settle conflicting identifiers. Where candidates are listed, + each carries its comparison rule; even a single candidate still requires a + decision. For every reason, render `candidate_count_is_lower_bound` as "at least N", never an exact total. - `missing` — no live ledger matched. Bridge never creates masters. 3. Call `build_import_xml` with the payload. It checks exact decimal balance, diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index 9bf17e69..364fc7c2 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -902,6 +902,34 @@ test("a family withheld under a different reason is not reported as a full repor root.unmount(); }); +test("identifier conflict guidance survives a truncated list with an outside candidate", async () => { + const mixedConflict = { + ...catalog, + targets: ["Outside candidate", "DN Party 001", "DN Party 002"], + bindings: [{ + row_position: 1, + entry_position: 1, + bound_target: null, + bound_basis: null, + unbound_reason: "master_binding_identifier_conflict", + candidates: ["Outside candidate"], + candidate_count: 31, + candidate_count_is_lower_bound: true, + candidate_listing: "truncated", + }], + }; + mocks.invoke.mockResolvedValueOnce(draft).mockResolvedValueOnce(mixedConflict); + const host = document.createElement("div"); + document.body.append(host); + const root = await mount(host, { catalogScope, catalogScopeKey: "mixed-identifier-conflict" }); + await act(async () => button(host, "Choose source XML").click()); + await act(async () => button(host, "Load existing ledgers").click()); + expect(host.textContent).toContain("Review it against the complete observed catalogue and confirm the intended identity before choosing"); + expect(host.textContent).toContain("full list of 3"); + expect(host.textContent).not.toContain("Use a fuller source name"); + root.unmount(); +}); + test("a materialized withheld family keeps its exact count", async () => { // Listing state and count precision are independent: a full prefix-family // union may deliberately withhold names without making its count an estimate. From dec42831330987bc7330504668b93a74ba2986f7 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 02:19:51 +0530 Subject: [PATCH 74/75] fix: preserve binding recovery evidence --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 8 +- .../src/master_binding_tests.rs | 102 +++++++----------- src-tauri/src/agent_import.rs | 30 ++++-- src-tauri/src/agent_import_tests.rs | 46 ++++++-- 5 files changed, 99 insertions(+), 89 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 16766ecb..b4d07cb3 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "7a0d85f1c5f585b59443d283535bda1c3dd3cba7cdaa89f04df8ba5a0ab957de", + "compatibility_surface_sha256": "70468e0fe723dc42e84c343816f7fe0909cdbaad29353216ae5469877a2276cb", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index f36ee5c3..69b512d3 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "502d89a9f40f9d4c2bdc7ed66772d7786642a1831d8884824fd6ce19a4b18ddc" + "sha256": "4caadad956d18f20012c413c24f391569cf92ede237fc5e83d67ede1fa62dc83" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -331,7 +331,7 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "54b31502b4c3b0e339fc04edded213244eac53b5cbf25471b0e1a1d9ec53bef6" + "sha256": "145bf17afc5065e1622b9e4551d8ef2f318e2b8bf5cd84c65917839e77717212" }, { "path": "src-tauri/src/agent_ledgers.rs", @@ -723,7 +723,7 @@ }, { "path": "src/SourceDraftScreen.tsx", - "sha256": "d7277e188c464b7f1b86b85022df4440b148ff04c782abf7a06de218292c9133" + "sha256": "18b991c944f6799f81a0c5dbdf58bed5650075923bf369e639db02333a278e8a" }, { "path": "src/TallyReadinessFlow.tsx", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "7a0d85f1c5f585b59443d283535bda1c3dd3cba7cdaa89f04df8ba5a0ab957de" + "manifest_sha256": "70468e0fe723dc42e84c343816f7fe0909cdbaad29353216ae5469877a2276cb" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index c3bb5025..eb9ae0ae 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -90,34 +90,22 @@ fn the_measured_transformations_compose() { // does on every comparison. Two reviewers raised that independently, so // §9.4d measured it rather than arguing it: eight composed variants, all // matched, all confirmed by day-book readback against the intended master. - let catalog = ledgers(&[ - "MB PILOT ALPHA", - "MB-PROBE-LEDGER-A", - "Beta Supply", - ]); + let catalog = ledgers(&["MB PILOT ALPHA", "MB-PROBE-LEDGER-A", "Beta Supply"]); for (supplied, expected) in [ - ( - " mb pilot alpha ", - "MB PILOT ALPHA", - ), + (" mb pilot alpha ", "MB PILOT ALPHA"), ("mb-pilot-alpha", "MB PILOT ALPHA"), - ( - " mb-pilot-alpha ", - "MB PILOT ALPHA", - ), - ( - "MB/PILOT ALPHA", - "MB PILOT ALPHA", - ), + (" mb-pilot-alpha ", "MB PILOT ALPHA"), + ("MB/PILOT ALPHA", "MB PILOT ALPHA"), ("mb-pilot alpha", "MB PILOT ALPHA"), - ( - " mb-pilot/alpha ", - "MB PILOT ALPHA", - ), + (" mb-pilot/alpha ", "MB PILOT ALPHA"), (" mb probe ledger a ", "MB-PROBE-LEDGER-A"), ] { let binding = bind_one_name(&catalog, supplied); - assert_eq!(binding.bound_name(), None, "{supplied:?} selected {expected:?}"); + assert_eq!( + binding.bound_name(), + None, + "{supplied:?} selected {expected:?}" + ); assert!( candidate_names(&binding).contains(&expected), "{supplied:?} did not offer {expected:?}" @@ -407,19 +395,33 @@ fn a_narrow_fold_candidate_survives_a_wide_fold_cap() { .iter() .zip(lower) .enumerate() - .map(|(bit, (upper, lower))| if mask & (1 << bit) == 0 { *upper } else { lower }) + .map(|(bit, (upper, lower))| { + if mask & (1 << bit) == 0 { + *upper + } else { + lower + } + }) .collect::(); - names.push(format!("{}{}", &spelling[..spelling.len() - 'Ζ'.len_utf8()], "/ζ")); + names.push(format!( + "{}{}", + &spelling[..spelling.len() - 'Ζ'.len_utf8()], + "/ζ" + )); } names.extend([ "Identifier Alpha (5550001234)".to_string(), "Identifier Beta (5550001234)".to_string(), ]); let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid catalog"); - let source = SourceEntity::with_identifier_hints(0, "ΑΒΓΔΕ/Ζ", ["5550001234"]) - .expect("valid source"); + let source = + SourceEntity::with_identifier_hints(0, "ΑΒΓΔΕ/Ζ", ["5550001234"]).expect("valid source"); let binding = bound(&catalog, &[source]).entities()[0].clone(); - let candidates = binding.unresolved().expect("candidate-only fold").candidates.listed(); + let candidates = binding + .unresolved() + .expect("candidate-only fold") + .candidates + .listed(); assert_eq!(candidates.len(), MAX_CANDIDATES_PER_ENTITY); assert_eq!(candidates[0].catalog_name, "Identifier Alpha (5550001234)"); assert_eq!(candidates[1].catalog_name, "Identifier Beta (5550001234)"); @@ -978,7 +980,10 @@ fn repeating_one_source_name_does_not_repeat_the_search_or_change_the_answer() { None, "a folded hit must remain a candidate" ); - assert_eq!(candidate_names(&report.entities()[1]), ["Acme Branch 00007"]); + assert_eq!( + candidate_names(&report.entities()[1]), + ["Acme Branch 00007"] + ); // Same source *name*, different identifier hints. The key is identical, so // a memo keyed on the key alone would hand the second entity the first @@ -1938,41 +1943,6 @@ fn candidate_order_is_rule_then_name_and_never_a_ranking() { ); } -#[test] -fn bounded_listing_keeps_narrow_evidence_before_long_same_rule_names() { - let narrow = "A/B"; - // The wider candidate sorts first lexicographically (`-` precedes `/`), - // so the old downstream name sort hid the narrow holder. - let long = "A-".to_string() + &"x".repeat(1020); - let catalog = ledgers(&[narrow, &long]); - let source = entity("A"); - let mut budget = 8_192; - let status = super::unresolved_from( - &catalog, - &source, - UnboundReason::NearMiss, - vec![ - (0, CandidateRule::NormalizedEqual), - (1, CandidateRule::NormalizedEqual), - ], - 2, - super::CountEvidence { - largest_withheld: None, - withheld_count_is_lower_bound: false, - }, - &mut budget, - ); - let listed = match &status { - BindingStatus::Ambiguous(unresolved) => unresolved.candidates.listed(), - _ => panic!("near miss must remain unresolved"), - }; - assert_eq!( - listed.first().expect("narrow candidate").catalog_name, - narrow - ); - assert_eq!(listed.len(), 2); -} - #[test] fn the_report_does_not_depend_on_the_order_the_book_returned() { let forward = ledgers(&["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA", "Beta Supply"]); @@ -2284,7 +2254,11 @@ fn fabricated_document() -> Vec<(&'static str, Option<&'static str>, Expected)> ("Cash", None, Expected::Bound("Cash")), ("CGST OUTPUT 9%", None, Expected::Bound("CGST OUTPUT 9%")), // Folded spelling is shown for review; it is not a selected master. - ("cgst output 9%", None, Expected::Unbound(UnboundReason::NearMiss)), + ( + "cgst output 9%", + None, + Expected::Unbound(UnboundReason::NearMiss), + ), ( " cgst output 9% ", None, diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index 5bc29301..0e785180 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -1710,21 +1710,29 @@ fn master_match_json(binding: &EntityBinding) -> Value { } } -fn master_recovery_guidance(report: &[Value]) -> &'static str { - if report.iter().any(|master| { - master["reason"] - .as_str() - .is_some_and(|reason| reason.contains("identifier")) - }) { - "For identifier conflicts, correct the source identity or explicitly select against the complete observed catalogue; use the exact_live_spelling from a fresh validate_masters result before building again. Do not copy a candidate automatically. No file was written." - } else if report +fn master_recovery_guidance(report: &[Value]) -> String { + let mut guidance = Vec::new(); + if report + .iter() + .any(|master| master["match_state"] == "identifier") + { + guidance + .push("For identifier-bound entries, copy exact_live_spelling from this fresh result."); + } + if report .iter() .any(|master| master["match_state"] == "missing") { - "For missing ledgers, correct the source spelling or have an operator create the legitimate missing ledger externally, then run validate_masters again before building. Do not create or choose a ledger automatically. No file was written." - } else { - "For near-misses, have an operator select the intended ledger and use its exact live spelling, then run validate_masters again before building. Do not copy a candidate automatically. No file was written." + guidance.push("For missing ledgers, correct the source spelling or have an operator create the legitimate ledger externally, then run validate_masters again."); + } + if report + .iter() + .any(|master| master["match_state"] == "near_miss") + { + guidance.push("For near-misses, have an operator explicitly select the intended ledger and run validate_masters again; do not copy a candidate automatically."); } + guidance.push("No file was written."); + guidance.join(" ") } fn render_import_xml(company: &str, vouchers: &[ImportVoucher], batch_id: &str) -> String { diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index f9ee2c47..7d31c87c 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -1617,15 +1617,17 @@ fn nothing_defensible_is_reported_missing_with_no_candidate() { #[test] fn import_recovery_guidance_names_the_state_and_next_safe_read() { - let identifier = - serde_json::json!({"reason":"master_binding_identifier_conflict", "match_state":"near_miss"}); - assert!(master_recovery_guidance(&[identifier]).contains("exact_live_spelling")); - let missing = - serde_json::json!({"reason":"master_binding_no_candidate", "match_state":"missing"}); - assert!(master_recovery_guidance(&[missing]).contains("legitimate missing ledger")); - let near_miss = - serde_json::json!({"reason":"master_binding_near_miss", "match_state":"near_miss"}); - assert!(master_recovery_guidance(&[near_miss]).contains("exact live spelling")); + let identifier = one_master_match("GAMMA 5550000001", &["GAMMA (5550000001)"]); + assert_eq!(identifier["match_state"], "identifier"); + assert!(identifier.get("reason").is_none()); + let guidance = master_recovery_guidance(&[ + identifier, + serde_json::json!({"match_state":"missing"}), + serde_json::json!({"match_state":"near_miss"}), + ]); + assert!(guidance.contains("exact_live_spelling")); + assert!(guidance.contains("legitimate ledger externally")); + assert!(guidance.contains("explicitly select")); } #[tokio::test] @@ -2187,3 +2189,29 @@ async fn current_dispatch_persists_its_reconciliation_verdict_before_returning_t 50 ); } + +#[test] +fn master_match_byte_cap_retains_narrow_fold_candidate() { + let suffix = "α".repeat(880); + let source = format!("αβγδεζ/{suffix}"); + let narrow = format!("αβγδεζ {suffix}"); + let upper = ['Α', 'Β', 'Γ', 'Δ', 'Ε', 'Ζ']; + let lower = ['α', 'β', 'γ', 'δ', 'ε', 'ζ']; + let mut names = vec![narrow.clone()]; + for mask in 1..=10 { + let prefix = upper + .iter() + .zip(lower) + .enumerate() + .map(|(bit, (u, l))| if mask & (1 << bit) == 0 { *u } else { l }) + .collect::(); + names.push(format!("{prefix}/{suffix}")); + } + assert!(names.iter().map(String::len).sum::() > 8192); + let borrowed = names.iter().map(String::as_str).collect::>(); + let rendered = one_master_match(&source, &borrowed); + let listed = rendered["candidates"].as_array().unwrap(); + assert_eq!(listed[0]["name"][super::super::PARTY_NAME_MARKER], narrow); + assert!(listed.iter().all(|v| v["rule"] == "normalized_equal")); + assert_eq!(rendered["candidates_truncated"], true); +} From 723a572c4895c025002aac0b3e2769536c9b8ff9 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 02:22:02 +0530 Subject: [PATCH 75/75] Make exact payload update and revalidation explicit in recovery --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- src-tauri/src/agent_import.rs | 2 +- src-tauri/src/agent_import_tests.rs | 2 ++ 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index b4d07cb3..a53c1231 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "70468e0fe723dc42e84c343816f7fe0909cdbaad29353216ae5469877a2276cb", + "compatibility_surface_sha256": "351975534f06a9821e9adf7403817f1f103e1b94cce6564718531b5f37657e77", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 69b512d3..e6faf880 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -331,7 +331,7 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "145bf17afc5065e1622b9e4551d8ef2f318e2b8bf5cd84c65917839e77717212" + "sha256": "709a76ae4daa0f2e2485f8c02d0126360705782a51e87d65ec001670acfe5c6e" }, { "path": "src-tauri/src/agent_ledgers.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "70468e0fe723dc42e84c343816f7fe0909cdbaad29353216ae5469877a2276cb" + "manifest_sha256": "351975534f06a9821e9adf7403817f1f103e1b94cce6564718531b5f37657e77" } \ No newline at end of file diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index 0e785180..23c44dca 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -1731,7 +1731,7 @@ fn master_recovery_guidance(report: &[Value]) -> String { { guidance.push("For near-misses, have an operator explicitly select the intended ledger and run validate_masters again; do not copy a candidate automatically."); } - guidance.push("No file was written."); + guidance.push("After operator review, update the payload to each confirmed exact live spelling and run validate_masters again before building. No file was written."); guidance.join(" ") } diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index 7d31c87c..0f816d14 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -1628,6 +1628,8 @@ fn import_recovery_guidance_names_the_state_and_next_safe_read() { assert!(guidance.contains("exact_live_spelling")); assert!(guidance.contains("legitimate ledger externally")); assert!(guidance.contains("explicitly select")); + assert!(guidance.contains("update the payload to each confirmed exact live spelling")); + assert!(guidance.contains("validate_masters again before building")); } #[tokio::test]