From a1fc8ff510bc9f2d0ae43f8d1bb29d6ef0643cda Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 04:58:40 +0530 Subject: [PATCH] Corroborate a nonempty window instead of assuming it complete `WindowRead::Complete` was asserted for any nonempty response, and that is not something a response can be inspected for: a silently short read returns rows that are all well-formed and all inside the window, and looks exactly like a full one. The voucher it omits then reads as `Absent` -- the one verdict in this engine that puts a duplicate in a client's book. So the range is re-read a day wider and the two reads are compared on Tally's own identities for the rows falling inside the original window. A row the wider read saw and the narrow one did not means the narrow read was short. A row carrying no `GUID` cannot be compared at all and is refused rather than assumed equal, and a row appearing between the two reads makes the window uncorroborated rather than absent. All of those fail towards `Partial`, which `BookWindow::observed` turns into `WindowIncomplete`. **The design is not mine.** It was rescued from an unowned worktree and is reviewed here rather than adopted: two corrections and a price. It did not build. The simulator cycle pins the request count and the run makes twenty-eight against the twenty-four asserted -- and the comment justifying that number said "the two extra are the wider voucher read and its paired status probe", when a corroborating read in this adapter is a *paired* read of six requests. The cost being pinned had been mis-estimated threefold. Two other simulator plans needed the extra read as well. **The price, measured against a live licensed 7.1 Silver book.** A one-day window on a dense corpus reads 895,888 bytes; its corroborating read of the same range widened a day either side reads 1,744,152 -- **1.95x** -- taking one presence call from ~896 KB to ~2.6 MB of Tally traffic. `max_bytes` is bounded at 5,000,000, so this roughly halves the widest window the tool can serve before corroboration alone fails the call. Wall-clock is not quoted: across runs on this lab it varied between six and twenty-six seconds for the same work, which is not a measurement of anything. The twenty-invoice replay still passes with it enabled -- sixteen present, four absent, unchanged. **What it establishes, and what it does not.** It catches a read that fell short because of *size*, which is the case the finding describes: a wider read is denser, so it truncates at least as hard, and a row the narrow read has and the wider one lacks proves the narrow read short. It does **not** catch a voucher both reads omit for a reason unrelated to size, and it is neither a source-side count nor a complete partitioning. It raises the bar; whether it clears the one the reviewer set is the reviewer's judgement, not mine. Pin set read off the base branch at reseal: none added, none removed. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 12 ++-- src-tauri/src/agent.rs | 46 ++++++++++++ src-tauri/src/agent_presence.rs | 40 +++++++---- src-tauri/src/agent_presence_tests.rs | 19 +++-- src-tauri/src/agent_tests.rs | 71 +++++++++++++++++++ src-tauri/src/agent_vouchers.rs | 33 +++++++++ 7 files changed, 199 insertions(+), 24 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 97393a8b..1f8d4867 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": "8b48f8f3bf66f52b41603ea85d7f25e4c2354b0fbdae31de2258e4e978666e97", + "compatibility_surface_sha256": "be2b3eea0bfc8bdaaa43f3527e56e521ab03fca15beaeded5e04d0ae47efa9c1", "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 137af6e9..4c19c659 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -327,7 +327,7 @@ }, { "path": "src-tauri/src/agent.rs", - "sha256": "e92609482da823a1c64974ed4995a67bed4ea946b323774b524b8def35ec7b52" + "sha256": "627c2983289deb593cc7ca11b1e9ea8be98dd877f9a3876bd634ba927d6691bc" }, { "path": "src-tauri/src/agent_desktop_journal.rs", @@ -339,11 +339,11 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "1451cc20c41ea5b7dde1de8ca1bb1d42bdc96a1f8bbbee178185d9b15d4f5efa" + "sha256": "d53f0060935ef55bf636cb9746f8196bc31c9df35f7fef16eb0187adaa4a0569" }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "886914f5848fb0d27417f289816eec5ba773b11ae17ac351a000eb190b12b3be" + "sha256": "bdef6e5fdd3878499334052a640729bafbc96462d05a11e1a50c2abd95a49370" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -359,7 +359,7 @@ }, { "path": "src-tauri/src/agent_tests.rs", - "sha256": "310b65fb872147335baa6e7ad92bc54bc5042e39aad5db70f3f4b7d37b921597" + "sha256": "9c94efe913aeaa2ac683a6cc28ceae948ca282390682d7ee0c0930d8267f63d9" }, { "path": "src-tauri/src/agent_trial_balance.rs", @@ -379,7 +379,7 @@ }, { "path": "src-tauri/src/agent_vouchers.rs", - "sha256": "721cc3958ddb165da160255aa236c13b1581fcba477382eed5b51e2dff81a9c6" + "sha256": "f650a6ea1cc81d363fb34026669774b6196bfe2c124ea576d5246d8eb7eec188" }, { "path": "src-tauri/src/commands.rs", @@ -858,5 +858,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "8b48f8f3bf66f52b41603ea85d7f25e4c2354b0fbdae31de2258e4e978666e97" + "manifest_sha256": "be2b3eea0bfc8bdaaa43f3527e56e521ab03fca15beaeded5e04d0ae47efa9c1" } \ No newline at end of file diff --git a/src-tauri/src/agent.rs b/src-tauri/src/agent.rs index fb8bd015..9c974c5d 100644 --- a/src-tauri/src/agent.rs +++ b/src-tauri/src/agent.rs @@ -950,6 +950,52 @@ fn corroborate_empty_voucher_window( } } +/// Whether a **nonempty** narrow read saw every voucher its window holds. +/// +/// `WindowRead::Complete` was asserted for any nonempty response, and that is +/// not something a response can be inspected for: a silently short read returns +/// rows that are all well-formed and all inside the window, and looks exactly +/// like a full one. The omitted voucher then reads as `Absent`, which is the +/// verdict that authorizes importing it a second time — the one wrong answer in +/// this engine that puts a duplicate in a client's book. +/// +/// So the range is re-read a day wider and the two reads are compared on +/// Tally's own identity for the rows falling inside the original window. A row +/// the wider read saw and the narrow one did not means the narrow read was +/// short. A row carrying no `GUID` cannot be compared at all and is refused +/// rather than assumed equal, and a row that appears between the two reads +/// makes the window uncorroborated rather than absent — both fail towards +/// `Partial`, which `BookWindow::observed` turns into `WindowIncomplete`. +fn corroborate_nonempty_voucher_window( + rows: &[Value], + widened_rows: &[Value], + from: &str, + to: &str, +) -> (bool, Option<&'static str>) { + match ( + window_identities(rows, from, to), + window_identities(widened_rows, from, to), + ) { + (Some(observed), Some(widened)) if observed == widened => (false, None), + (Some(_), Some(_)) => (true, Some("window_short_read")), + _ => (true, Some("window_identity_unreadable")), + } +} + +/// The multiset of Tally identities the rows carry inside the window, or +/// `None` if any of them carries none. +fn window_identities(rows: &[Value], from: &str, to: &str) -> Option> { + let mut identities: BTreeMap = BTreeMap::new(); + for row in rows.iter().filter(|row| row_in_window(row, from, to)) { + let guid = row.get("guid").and_then(Value::as_str)?; + if guid.is_empty() { + return None; + } + *identities.entry(guid.to_string()).or_insert(0) += 1; + } + Some(identities) +} + fn row_in_window(row: &Value, from: &str, to: &str) -> bool { row.get("date") .and_then(Value::as_str) diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index d9957ad5..2666aa1f 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -124,20 +124,34 @@ impl Server { // An empty window is only an empty window once the existing // corroboration says so. Anything less becomes `WindowIncomplete` // at the crate boundary rather than a report full of "absent". + // Completeness is corroborated, never assumed, and that holds + // whether or not the window had rows in it. An empty read is + // checked against a wider read and the company high-water mark; a + // nonempty read is checked against a wider read on Tally's own + // identities. A silently short response looks exactly like a full + // one, and the verdict it produces -- `Absent` -- is what + // authorises importing a voucher the book already holds. let mut read = WindowRead::Complete; - let mut reason = None; - if rows.is_empty() { - let (read_evidence, partial, corroboration) = self - .corroborate_empty_voucher_read(&identity, &company.name, &from, &to, None) - .await?; - accumulate(&mut accumulated, read_evidence); - reason = corroboration; - if partial { - read = WindowRead::Partial; - if let Some(evidence) = accumulated.as_mut() { - evidence.state = "partial"; - evidence.reason_code = corroboration.map(str::to_string); - } + let (read_evidence, partial, reason) = if rows.is_empty() { + self.corroborate_empty_voucher_read(&identity, &company.name, &from, &to, None) + .await? + } else { + self.corroborate_nonempty_voucher_read( + &identity, + &company.name, + &from, + &to, + None, + &rows, + ) + .await? + }; + accumulate(&mut accumulated, read_evidence); + if partial { + read = WindowRead::Partial; + if let Some(evidence) = accumulated.as_mut() { + evidence.state = "partial"; + evidence.reason_code = reason.map(str::to_string); } } diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index 80450dc2..bf6d8917 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -513,11 +513,14 @@ fn plans(steps: Vec) -> Vec { fn presence_plans() -> Vec { let catalogue = catalogue_xml(); let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status]; - // Catalogue, then the voucher window, then the catalogue again: the - // verdict is built from two observations and the second read proves the - // first still holds. + // Catalogue, the voucher window, the *wider* window that corroborates it, + // then the catalogue again: the verdict is built from observations that + // each prove the one before them still holds. The wider read returns the + // same rows here, so the identities inside the window agree and the read + // is complete. steps.extend(paired_read(&catalogue)); steps.extend(paired_read(&window_xml())); + steps.extend(paired_read(&window_xml())); steps.extend(paired_read(&catalogue)); plans(steps) } @@ -604,7 +607,13 @@ async fn a_live_shaped_cycle_separates_present_undecided_and_absent() { "complete" ); let observed = simulator.finish().expect("requests"); - assert_eq!(observed.len(), 22); + // Twenty-two before corroborating nonempty windows, twenty-eight after. + // The six extra are one *paired* read, which is what a corroborating read + // costs in this adapter -- company, payload, status, payload, status, + // company -- not the two that one more request would suggest. Pinned so + // the cost stays a decision rather than a drift, and pinned at what it + // measures rather than at what it was first estimated to be. + assert_eq!(observed.len(), 28); } /// The admission contract this tool enforces lives in `agent_catalog.rs`, and @@ -739,6 +748,8 @@ async fn a_ledger_missing_from_the_catalogue_fails_closed() { let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status]; steps.extend(paired_read(&catalogue)); steps.extend(paired_read(&unlisted)); + // The nonempty window is corroborated by a wider read before the verdict. + steps.extend(paired_read(&unlisted)); steps.extend(paired_read(&catalogue)); let simulator = SequenceSimulator::spawn(plans(steps)).expect("simulator"); let directory = tempfile::tempdir().expect("directory"); diff --git a/src-tauri/src/agent_tests.rs b/src-tauri/src/agent_tests.rs index a65215e8..a0b2cbb0 100644 --- a/src-tauri/src/agent_tests.rs +++ b/src-tauri/src/agent_tests.rs @@ -1462,6 +1462,77 @@ fn voucher_window_rejects_out_of_range_rows_and_requires_a_wider_empty_check() { ); } +#[test] +fn a_nonempty_window_is_corroborated_rather_than_assumed_complete() { + // `WindowRead::Complete` used to be asserted for any nonempty response. + // A silently short read is indistinguishable from a full one by + // inspection — every row it returns is well-formed and inside the window — + // and the voucher it omits reads as `Absent`, which is the verdict that + // authorizes importing a voucher the book already holds. + let narrow = [ + json!({"date":"20260901","guid":"g-1"}), + json!({"date":"20260902","guid":"g-2"}), + ]; + + // The wider read agrees, and rows outside the window are irrelevant to it. + assert_eq!( + corroborate_nonempty_voucher_window( + &narrow, + &[ + json!({"date":"20260831","guid":"g-0"}), + json!({"date":"20260901","guid":"g-1"}), + json!({"date":"20260902","guid":"g-2"}), + json!({"date":"20260903","guid":"g-3"}), + ], + "20260901", + "20260902", + ), + (false, None) + ); + + // The wider read saw a voucher inside the window that the narrow read did + // not. That is the short read, and it must not become a verdict. + assert_eq!( + corroborate_nonempty_voucher_window( + &narrow, + &[ + json!({"date":"20260901","guid":"g-1"}), + json!({"date":"20260901","guid":"g-9"}), + json!({"date":"20260902","guid":"g-2"}), + ], + "20260901", + "20260902", + ), + (true, Some("window_short_read")) + ); + + // A row carrying no identity cannot be compared, so it is refused rather + // than assumed equal — on either side. + for (rows, widened) in [ + (&narrow[..], &[json!({"date":"20260901"})][..]), + (&[json!({"date":"20260901"})][..], &narrow[..]), + ] { + assert_eq!( + corroborate_nonempty_voucher_window(rows, widened, "20260901", "20260902"), + (true, Some("window_identity_unreadable")) + ); + } + + // Same identities, different multiplicity, is still a disagreement. + assert_eq!( + corroborate_nonempty_voucher_window( + &[json!({"date":"20260901","guid":"g-1"})], + &[ + json!({"date":"20260901","guid":"g-1"}), + json!({"date":"20260902","guid":"g-1"}), + ], + "20260901", + "20260902", + ), + (true, Some("window_short_read")) + ); +} + #[test] fn empty_voucher_window_corroboration_handles_all_three_control_branches() { assert_eq!( diff --git a/src-tauri/src/agent_vouchers.rs b/src-tauri/src/agent_vouchers.rs index 60db4f43..ab761d31 100644 --- a/src-tauri/src/agent_vouchers.rs +++ b/src-tauri/src/agent_vouchers.rs @@ -155,6 +155,39 @@ fn accumulate_evidence(target: &mut Option, next: Evidence) { } impl Server { + /// The nonempty counterpart of `corroborate_empty_voucher_read`: one wider + /// read, compared against what the narrow read reported for the same range. + /// + /// It costs a second read on every window that has vouchers in it. The empty + /// path already paid that, and the alternative is asserting completeness + /// from a response that cannot show it. + pub(super) async fn corroborate_nonempty_voucher_read( + &self, + identity: &VerifiedCompanyIdentity, + company: &str, + from: &str, + to: &str, + ledger: Option<&str>, + rows: &[Value], + ) -> Result<(Evidence, bool, Option<&'static str>), ToolFailure> { + let (wider_from, wider_to) = widened_window(from, to)?; + let wider_request = render_agent_vouchers(company, &wider_from, &wider_to, None)?; + let (wider_xml, evidence) = self.post_read(identity, wider_request).await?; + let outcome = async { + let wider_rows = validate_then_filter_voucher_rows( + parse_agent_rows(&wider_xml, identity.company_guid())?, + &wider_from, + &wider_to, + ledger, + )?; + let (partial, reason) = + corroborate_nonempty_voucher_window(rows, &wider_rows, from, to); + Ok((evidence.clone(), partial, reason)) + } + .await; + outcome.map_err(|failure: ToolFailure| failure.with_prior_evidence(evidence)) + } + pub(super) async fn corroborate_empty_voucher_read( &self, identity: &VerifiedCompanyIdentity,