diff --git a/developer-simulation/.gitignore b/developer-simulation/.gitignore new file mode 100644 index 0000000..8f66aca --- /dev/null +++ b/developer-simulation/.gitignore @@ -0,0 +1,5 @@ +**/target/ +**/*.db/ +**/*.db-shm +**/*.db-wal +**/.DS_Store diff --git a/developer-simulation/AUTOMATION_PROMPT.md b/developer-simulation/AUTOMATION_PROMPT.md new file mode 100644 index 0000000..7405b8b --- /dev/null +++ b/developer-simulation/AUTOMATION_PROMPT.md @@ -0,0 +1,156 @@ +# Daily automation instructions + +Run the BogKit developer simulation lab for the current date in +`America/New_York`. + +The user explicitly authorized multi-agent work for this automation. Use a +scenario designer, two independent simulated developers, and a skeptical +reviewer. Keep the coordinator responsible for repository and GitHub writes. + +## Fixed targets + +- Repository: `flowercomputers/bogkit` +- Base branch: `main` +- Simulation branch: `ed/developer-simulation` +- Pull request: the open PR whose head is `ed/developer-simulation` +- Archive root: `developer-simulation/` +- Daily PR marker: `` +- Dashboard marker: `` + +## Safety boundary + +- Modify only `developer-simulation/`. +- Never automatically edit BogKit core, existing examples, root workspace + configuration, or repository-wide documentation. +- Never force-push, merge the PR, create another PR, or open issues. +- Never commit credentials, private data, generated build output, databases, + large binary fixtures, or unjustified dependencies. +- Stop and report a blocker if current changes, merge conflicts, missing GitHub + access, or an unexpected branch state make a safe run uncertain. + +## Preflight and idempotency + +1. Read `developer-simulation/README.md`, `coverage.json`, and recent reports. +2. Verify GitHub access and resolve the open draft PR by its exact head branch. +3. Fetch `origin`. Start from the current remote simulation branch and merge the + latest `origin/main` without rewriting history. Abort safely on conflict. +4. Search both the branch and PR conversation for today's daily marker. + - If both exist, make no changes and finish successfully. + - If the report exists but the PR comment is missing, publish the existing + report, refresh the dashboard, and do not generate new trials. + - If a partial dated archive exists without a finished report, inspect and + resume only confirmed work. Do not create a second dated archive. + +## Scenario design + +Spawn a scenario-designer subagent without inherited conversation context. Do +not let it inspect BogKit. Give it only the coverage ledger and ask for two +substantially different, underexplored existing-software problems. + +Each scenario must specify: + +- a developer role and Rust experience level; +- the existing system and baseline implementation; +- the concrete pain, workload, data shape, and operational constraints; +- measurable acceptance criteria and explicit non-goals; +- a compact, self-contained prototype boundary. + +Do not reverse-engineer scenarios around Fold, ESE, or ANNy. Avoid repeating +games, generic agent memory, media search, or repository search unless the +coverage ledger shows a materially new workload or constraint. + +## Independent developer trials + +Create two separate sanitized temporary checkouts of current `origin/main`. +They must not contain `developer-simulation/` or prior lab reports. + +Spawn one fresh simulator subagent per checkout without inherited conversation +context. Give each only its persona, problem brief, assigned checkout, and these +rules: + +- begin with the public README and examples; +- behave as a developer with no prior BogKit knowledge; +- do not read the other trial or prior lab work; +- evaluate the stated baseline before choosing BogKit; +- use only the BogKit components that fit, and allow a no-fit conclusion; +- build the smallest meaningful runnable prototype or failure reproducer; +- test, format, lint with warnings denied, and run the demonstration; +- record the ordered discovery and friction trail, exact commands and observed + results, categorized findings, and a decision audit; +- make no repository, GitHub, or automation writes. + +Run the simulators in parallel only when their directories are disjoint. + +## Skeptical review + +Spawn a separate reviewer after both trials finish. The reviewer may inspect +both prototypes, current BogKit source, and prior reports, but makes no +repository or GitHub writes. + +Require the reviewer to: + +- reproduce important behavior and any serious defect; +- compare each solution with the scenario's baseline; +- reject, soften, or relabel claims that exceed the evidence; +- identify unnecessary dependencies, abstractions, or test scope; +- audit consequential choices and unresolved uncertainty; +- enforce the finding and candidate thresholds in `README.md`. + +The coordinator fixes rejected quality problems and reruns validation before +archiving. + +## Archive and validation + +Archive accepted runnable prototypes in flat, uniquely named directories: + +```text +developer-simulation/runs/YYYY-MM-DD--short-slug/ +``` + +Each runnable Rust prototype must be a member of the nested workspace, use path +dependencies on the checkout being tested, and include a README with exact +reproduction instructions. Archive a blocked trial only as its minimal +reproducer. + +Write one synthesis at `developer-simulation/reports/YYYY-MM-DD.md` following +`REPORT_TEMPLATE.md`. Update `coverage.json` with both trials and independently +recurring findings. + +Before publication: + +1. Run each new prototype's tests, formatting check, strict lint, and demo. +2. Run the nested lab workspace tests and strict lint. +3. Run `cargo test --workspace` from the BogKit root. +4. Run `git diff --check`. +5. Inspect every changed path and confirm it is under `developer-simulation/`. +6. Scan the archive for secrets, generated databases, build output, and large + or binary files. + +## Publication + +Commit the dated archive with: + +```text +simulation: YYYY-MM-DD developer trials +``` + +Push normally to `origin/ed/developer-simulation`. If the push is rejected, +fetch and inspect the divergence; do not force-push. + +After the commit is confirmed on the remote: + +1. Append one top-level PR comment containing the daily report and its marker. +2. Find the existing dashboard comment by its marker and replace its body with + a compact current dashboard: + - total trials and outcome counts; + - coverage summary; + - confirmed defects; + - recurring API or documentation friction; + - candidate improvements that meet the threshold; + - no-fit and positioning signals; + - links to every dated report comment and branch report. +3. Verify the daily comment and dashboard through a fresh read. + +If the branch push succeeds but commenting fails, keep the branch evidence, +retry only the missing comment step, and fail visibly. Never generate duplicate +trials to repair a reporting failure. diff --git a/developer-simulation/Cargo.lock b/developer-simulation/Cargo.lock new file mode 100644 index 0000000..9469978 --- /dev/null +++ b/developer-simulation/Cargo.lock @@ -0,0 +1,1844 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anny" +version = "0.0.1" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "byteview" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d74937895e761d5984206f82ca8ff7725d0fd8021921011921894b41ab1b9f7" + +[[package]] +name = "caldav-recurrence-prototype" +version = "0.1.0" +dependencies = [ + "chrono", + "fold", + "libc", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "carrier-label-ambiguity" +version = "0.1.0" + +[[package]] +name = "catalog-compiler-prototype" +version = "0.1.0" + +[[package]] +name = "causal-canvas-compaction" +version = "0.1.0" +dependencies = [ + "fold", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ci-lease-coordinator" +version = "0.0.0" +dependencies = [ + "fold", + "serde", + "serde_json", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cnc-job-bundle-preflight" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "cold-chain-repair" +version = "0.1.0" +dependencies = [ + "fold", + "serde", + "serde_json", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compare" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0095f6103c2a8b44acd6fd15960c801dafebf02e21940360833e0673f48ba7" + +[[package]] +name = "container-yard-planner" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dns-change-gate" +version = "0.1.0" + +[[package]] +name = "edge-spool-pressure" +version = "0.1.0" +dependencies = [ + "fold", + "serde", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "ese" +version = "0.1.0" +dependencies = [ + "minreq", + "serde_json", + "unicode-general-category", + "unicode-normalization", +] + +[[package]] +name = "fastq-barcode-spill" +version = "0.1.0" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "financial-snapshot-trial" +version = "0.1.0" +dependencies = [ + "fold", + "serde", + "serde_json", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "firewall-policy-impact" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "fjall" +version = "3.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420a84699b8ccbb1ed573e38e88f4f23637b45beab6432066452f834be469c57" +dependencies = [ + "byteorder-lite", + "byteview", + "dashmap", + "flume", + "log", + "lsm-tree", + "tempfile", + "xxhash-rust", +] + +[[package]] +name = "flash-config-journal" +version = "0.0.0" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "spin", +] + +[[package]] +name = "fold" +version = "0.0.1" +dependencies = [ + "anny", + "fjall", + "fxhash", + "postcard", + "serde", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraud-velocity-evaluation" +version = "0.1.0" + +[[package]] +name = "freight-clearing" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "tempfile", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "homecare-gap-fill" +version = "0.1.0" +dependencies = [ + "chrono", + "fold", + "serde", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-cache-revalidation" +version = "0.1.0" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "incremental-syntax-highlighting" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "instrument-decoder-trial" +version = "0.1.0" + +[[package]] +name = "interval-heap" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11274e5e8e89b8607cfedc2910b6626e998779b48a019151c7604d0adcb86ac6" +dependencies = [ + "compare", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lab-unit-gate" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "tempfile", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lsm-tree" +version = "3.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "055a908d502129cf63bedae52f2db222e4436d2da32a69df9b84ac9fb9147761" +dependencies = [ + "byteorder-lite", + "byteview", + "crossbeam-skiplist", + "enum_dispatch", + "interval-heap", + "log", + "quick_cache", + "rustc-hash", + "self_cell", + "sfa", + "tempfile", + "varint-rs", + "xxhash-rust", +] + +[[package]] +name = "mailbox-mirror-lab" +version = "0.1.0" +dependencies = [ + "clap", + "fold", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "midi-scheduler-rt-model" +version = "0.1.0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minreq" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05015102dad0f7d61691ca347e9d9d9006685a64aefb3d79eecf62665de2153d" +dependencies = [ + "native-tls", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mixed-version-contract-gate" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ocr-redaction-remap" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "unicode-normalization", + "unicode-segmentation", +] + +[[package]] +name = "offline-door-policy-fit-probe" +version = "0.1.0" +dependencies = [ + "fold", + "libc", + "serde", +] + +[[package]] +name = "offline-flag-parity" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "offline-reconciliation" +version = "0.1.0" +dependencies = [ + "fold", + "serde", + "serde_json", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parcel-delta-tiles" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "parts-catalog-evolution" +version = "0.1.0" +dependencies = [ + "axum", + "http-body-util", + "serde", + "serde_json", + "tokio", + "tower", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "provenance-revocation-reproducer" +version = "0.1.0" +dependencies = [ + "fold", + "serde", + "serde_json", +] + +[[package]] +name = "purchase-audit-comparison" +version = "0.1.0" +dependencies = [ + "fold", + "serde", +] + +[[package]] +name = "quick_cache" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "reach-rollup-lab" +version = "0.1.0" + +[[package]] +name = "receiving-slot-admission" +version = "0.1.0" +dependencies = [ + "fold", + "serde", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "remittance-reconciliation" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "repair-cafe-fold-trial" +version = "0.1.0" +dependencies = [ + "fold", + "libc", + "serde", +] + +[[package]] +name = "return-reconciler-trial1" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "revision-safe-manual-search-trial" +version = "0.1.0" +dependencies = [ + "fold", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sfa" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1296838937cab56cd6c4eeeb8718ec777383700c33f060e2869867bd01d1175" +dependencies = [ + "byteorder-lite", + "log", + "xxhash-rust", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "snapshot-gc-safety" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "support-case-finder" +version = "0.1.0" +dependencies = [ + "anny", + "ese", + "fold", + "serde", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "transparency-checkpoint-verifier" +version = "0.1.0" +dependencies = [ + "ring", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "undo-history-lab" +version = "0.1.0" +dependencies = [ + "crc32fast", + "fold", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +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 = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "varint-rs" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa6c38708f6257f1ec2ca7e5a11f9bbf58a27d7060078b6b333624968183d96" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "water-repair" +version = "0.1.0" +dependencies = [ + "libc", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "webhook-scheduler" +version = "0.1.0" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/developer-simulation/Cargo.toml b/developer-simulation/Cargo.toml new file mode 100644 index 0000000..6a870e0 --- /dev/null +++ b/developer-simulation/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +resolver = "3" +members = ["runs/*"] + +[workspace.package] +edition = "2024" +publish = false + +[profile.release] +codegen-units = 1 +lto = "thin" +strip = true diff --git a/developer-simulation/DASHBOARD_TEMPLATE.md b/developer-simulation/DASHBOARD_TEMPLATE.md new file mode 100644 index 0000000..2fa4b5b --- /dev/null +++ b/developer-simulation/DASHBOARD_TEMPLATE.md @@ -0,0 +1,43 @@ +# BogKit simulated-developer dashboard + + + +This comment is the rolling index for the daily developer simulation lab. +Detailed reports are appended as immutable PR comments. + +## Current totals + +- Trials: +- Adopted: +- Partially adopted: +- No fit: +- Blocked: + +## Coverage + +- Roles: +- Domains: +- BogKit components considered: + +## Confirmed defects + +None yet. + +## Recurring friction + +None yet. + +## Candidate improvements + +Suggestions appear here only after two independent trials, or after one serious +reproducible correctness defect. + +None yet. + +## No-fit and positioning signals + +None yet. + +## Reports + +No runs yet. diff --git a/developer-simulation/README.md b/developer-simulation/README.md new file mode 100644 index 0000000..ecd9872 --- /dev/null +++ b/developer-simulation/README.md @@ -0,0 +1,99 @@ +# BogKit developer simulation lab + +This directory is a living research corpus for daily trials by simulated +developers who encounter BogKit without prior product knowledge. + +The goal is product feedback, not example volume. Every trial begins with an +existing software problem and asks whether BogKit makes the solution measurably +better, clearer, or simpler. A trial may conclude that BogKit is not a good fit. + +## Boundaries + +- Daily work stays under `developer-simulation/`. +- The automation never changes `fold`, `anny`, `ese`, existing examples, or + other BogKit files. +- Core changes remain proposals until a maintainer explicitly approves them. +- Prototypes contain no credentials, private data, generated build output, + large binary fixtures, or unjustified dependencies. +- A failed integration is reduced to the smallest useful reproducer. + +## Daily protocol + +1. Sync the simulation branch with the latest `origin/main` without rewriting + history. +2. Read `coverage.json` and prior reports. Select underexplored combinations of + developer role, domain, workload, constraints, and BogKit components. +3. Ask a scenario designer that has not inspected BogKit to produce two + realistic briefs. Each brief must define: + - the developer's role and level of Rust experience; + - an existing system and its current approach; + - the concrete problem, constraints, and baseline; + - acceptance criteria and explicit non-goals. +4. Give each brief to a fresh simulator in a sanitized checkout of current + `main`. The simulator starts at the public README and examples. It must not + read prior simulation runs before finishing its own attempt. +5. Each simulator builds the smallest meaningful prototype, runs it, tests it, + and records its discovery and debugging trail. It may adopt part of BogKit + or reject the toolkit. +6. A separate skeptical reviewer reproduces important claims, challenges + unnecessary choices, and rejects vague or unsupported recommendations. +7. Archive accepted prototypes in `runs/`, write the daily synthesis in + `reports/`, update `coverage.json`, and validate the full corpus. +8. Commit and push the evidence before adding the immutable daily PR comment + and updating the rolling dashboard comment. + +## Archive contract + +Runnable trials live at: + +```text +runs/YYYY-MM-DD--short-slug/ +``` + +Each runnable directory is a member of this nested Cargo workspace and includes +its own README. Package names must be unique. Path dependencies point to the +current BogKit checkout so the archived code exercises the branch being tested. + +Daily reports live at: + +```text +reports/YYYY-MM-DD.md +``` + +The report follows `REPORT_TEMPLATE.md`. Exact commands and observed results are +required. Performance claims require a stated baseline, release-mode runs, and +enough repetitions to avoid presenting a one-off timing as a conclusion. + +## Finding policy + +Findings use one of these categories: + +- correctness defect +- performance problem +- API friction +- documentation gap +- missing capability +- poor product fit + +Every finding includes evidence, severity, confidence, a reproduction path, and +the smallest plausible improvement. Prefer documentation or examples before a +new API, and a small API correction before a new subsystem. + +A feature suggestion becomes a dashboard candidate only when: + +- two independent trials encounter the same need; or +- one trial demonstrates a serious reproducible correctness problem. + +One-off ideas remain observations. + +## Idempotency + +Each daily report and PR comment uses the marker: + +```text + +``` + +If the dated report and comment already exist, a repeated run does nothing. If +the branch evidence exists but the comment is missing, the coordinator posts +the missing comment without generating new trials. diff --git a/developer-simulation/REPORT_TEMPLATE.md b/developer-simulation/REPORT_TEMPLATE.md new file mode 100644 index 0000000..251c2de --- /dev/null +++ b/developer-simulation/REPORT_TEMPLATE.md @@ -0,0 +1,61 @@ +# Developer simulation — YYYY-MM-DD + + + +## Trial 1 — title + +- Persona: +- Existing system: +- Problem: +- Outcome: adopted / partially adopted / no fit / blocked +- BogKit components considered: +- BogKit components used: + +### Evidence + +- Commands: +- Tests: +- Demonstration: +- Baseline comparison: + +### Friction trail + +Record the order in which the developer discovered, attempted, misunderstood, +debugged, and resolved each important step. + +### Findings + +For each finding include category, severity, confidence, reproduction, and the +smallest plausible improvement. + +### Decision audit + +List consequential implementation choices, alternatives rejected, and choices +the simulator is not confident about. + +## Trial 2 — title + +Use the same structure as Trial 1. + +## Skeptical review + +- Claims reproduced: +- Claims rejected or softened: +- Unnecessary code or dependencies removed: +- Remaining uncertainty: + +## Cross-run synthesis + +- New evidence: +- Recurring evidence: +- Candidate improvements: +- Observations not yet promoted: +- No-fit or positioning signals: + +## Validation + +- Trial-specific tests: +- Strict lint and formatting: +- Runnable demonstrations: +- BogKit root workspace tests: +- Archive and secret checks: diff --git a/developer-simulation/coverage.json b/developer-simulation/coverage.json new file mode 100644 index 0000000..93dbd7d --- /dev/null +++ b/developer-simulation/coverage.json @@ -0,0 +1,1037 @@ +{ + "version": 1, + "purpose": "Track coverage and prevent repetitive or reverse-engineered BogKit scenarios.", + "selection_policy": { + "daily_trials": 2, + "allow_no_fit": true, + "prefer_underexplored_combinations": true, + "candidate_threshold": "two independent trials or one serious reproducible correctness defect" + }, + "prior_submission_coverage": [ + { + "project": "Area denial", + "domain": "location-based multiplayer game", + "roles": ["game backend developer"], + "components": ["fold"] + }, + { + "project": "bog-bench", + "domain": "agent tooling observability", + "roles": ["developer tools engineer"], + "components": ["fold"] + }, + { + "project": "Obsidian base views", + "domain": "local-first knowledge interface", + "roles": ["desktop application developer"], + "components": ["fold"] + }, + { + "project": "tompkins-audio-search", + "domain": "multimodal archive search", + "roles": ["media data engineer"], + "components": ["fold", "ese", "anny"] + }, + { + "project": "seance", + "domain": "time-travel repository search", + "roles": ["developer tools engineer"], + "components": ["fold", "ese", "anny"] + }, + { + "project": "MUDGarden", + "domain": "persistent simulated world", + "roles": ["game and agent systems developer"], + "components": ["fold"] + }, + { + "project": "Untitled Mobile FPS", + "domain": "mobile multiplayer game", + "roles": ["mobile and game backend developer"], + "components": ["fold", "ese", "anny"] + } + ], + "underexplored_domains": [ + "business workflow and audit", + "build and deployment infrastructure", + "edge telemetry and operations", + "embedded or resource-constrained software", + "data synchronization", + "fraud and anomaly detection", + "scheduling and marketplaces", + "ordinary CRUD systems with uncertain fit" + ], + "runs": [ + { + "date": "2026-07-28", + "slug": "purchase-audit", + "persona": "finance software developer, intermediate Rust", + "domain": "business workflow and audit", + "constraints": [ + "PostgreSQL remains source of truth", + "state and audit event require one transaction", + "role-protected seven-year audit retention" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": ["fold"], + "outcome": "no_fit", + "report": "reports/2026-07-28.md" + }, + { + "date": "2026-07-28", + "slug": "offline-reconciliation", + "persona": "warehouse device developer, Rust beginner", + "domain": "data synchronization", + "constraints": [ + "PostgreSQL remains source of truth", + "horizontally deployed API", + "duplicate, reordered, and interrupted uploads", + "no trusted cross-device clock" + ], + "components_considered": ["fold"], + "components_used": ["fold"], + "outcome": "local_proof_only_no_production_fit", + "report": "reports/2026-07-28.md" + }, + { + "date": "2026-07-29", + "slug": "edge-spool-pressure", + "persona": "site reliability engineer, intermediate Rust", + "domain": "edge telemetry and operations", + "constraints": [ + "strict 256 MiB allocated spool limit", + "64 MiB available memory", + "priority-preserving retention and explainable drops", + "process interruption during writes and uploads" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": ["fold"], + "outcome": "no_fit", + "report": "reports/2026-07-29.md" + }, + { + "date": "2026-07-29", + "slug": "flash-config-journal", + "persona": "embedded controls developer, beginner-to-intermediate Rust", + "domain": "embedded or resource-constrained software", + "constraints": [ + "128 KiB raw NOR flash with 4 KiB erase blocks", + "16 KiB working-memory target", + "power loss after any modeled written byte", + "bounded boot scan and wear distribution" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-07-29.md" + }, + { + "date": "2026-07-30", + "slug": "ci-lease-coordinator", + "persona": "CI platform engineer, intermediate Rust", + "domain": "build and deployment infrastructure", + "constraints": [ + "three concurrently active coordinator replicas", + "duplicate, reordered, and late worker messages", + "100 post-acknowledgment process exits", + "100,000 queued jobs and 2,000 updates per second" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": ["fold"], + "outcome": "no_fit", + "report": "reports/2026-07-30.md" + }, + { + "date": "2026-07-30", + "slug": "fraud-velocity-evaluation", + "persona": "fraud infrastructure developer, Rust beginner", + "domain": "fraud and anomaly detection", + "constraints": [ + "four consumer replicas with partition reassignment", + "event-time windows with deterministic linked corrections", + "atomic durable-stream offset ownership", + "verified customer deletion and exact alert explanations" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-07-30.md" + }, + { + "date": "2026-07-31", + "slug": "homecare-gap-fill", + "persona": "scheduling-platform developer, intermediate Rust", + "domain": "home-care scheduling and dispatch", + "constraints": [ + "SQLite remains authoritative", + "20,000 caregivers and 120,000 visits over 14 days", + "200 cancellation changes per second with stable assignments", + "deterministic explanations, restart recovery, and explicit time offsets" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": ["fold"], + "outcome": "no_fit", + "report": "reports/2026-07-31.md" + }, + { + "date": "2026-07-31", + "slug": "parts-catalog-evolution", + "persona": "wholesale catalog backend developer, beginner-to-intermediate Rust", + "domain": "ordinary CRUD and runtime schema evolution", + "constraints": [ + "existing SQLite file and HTTP shapes remain authoritative", + "250,000 products with category-specific nested attributes", + "conditional edits and resumable bulk import", + "single 512 MiB VM and storage below 1.5 times baseline" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-07-31.md" + }, + { + "date": "2026-08-01", + "slug": "offline-flag-parity", + "persona": "client-platform SDK developer, Rust beginner with production TypeScript experience", + "domain": "offline feature-flag evaluation and configuration admission", + "constraints": [ + "5,000 flags and 50,000 ordered targeting rules", + "cross-implementation deterministic decisions and explanations", + "failed reload preserves the active in-process snapshot", + "64 MiB measured process-memory target and 250 microsecond p95 target" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-01.md" + }, + { + "date": "2026-08-01", + "slug": "cnc-job-bundle-preflight", + "persona": "manufacturing software engineer, intermediate Rust", + "domain": "offline manufacturing job-bundle validation and staging", + "constraints": [ + "untrusted classic stored-ZIP subset with deterministic diagnostics", + "1,000 declared files and a streamed 2 GiB sparse-member check", + "32 MiB measured process-memory target", + "no final ready name until a complete rechecked copy succeeds" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-01.md" + }, + { + "date": "2026-08-02", + "slug": "carrier-label-ambiguity", + "persona": "fulfillment-platform engineer, intermediate Rust with strong TypeScript experience", + "domain": "fulfillment workflow and external purchase ambiguity", + "constraints": [ + "carrier remains authoritative for charges and PostgreSQL for workflow state", + "20,000 shipments across 30 deterministic fault seeds", + "ambiguous timeouts, duplicate and reordered callbacks, and process exits", + "no automatic repurchase after an inconclusive carrier outcome" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-02.md" + }, + { + "date": "2026-08-02", + "slug": "snapshot-gc-safety", + "persona": "backup-tools maintainer, Rust beginner with production Python experience", + "domain": "backup retention and filesystem garbage-collection safety", + "constraints": [ + "existing JSONL manifests and content-addressed blob layout remain authoritative", + "1,000,000 references across 10,000 manifests and 300,000 blobs", + "128 MiB measured process-memory target and bounded scratch space", + "cooperative publication lock, recoverable quarantine, and restart idempotence" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-02.md" + }, + { + "date": "2026-08-03", + "slug": "provenance-revocation-impact", + "persona": "release-security platform engineer, intermediate Rust", + "domain": "software supply-chain provenance and revocation impact", + "constraints": [ + "PostgreSQL remains authoritative during continuous manifest ingestion", + "transitive revocation, missing manifests, duplicate edges, and malformed cycles", + "deterministic explanation paths and generation-safe publication", + "500,000-artifact and 5-million-edge scale target" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": ["fold"], + "outcome": "no_fit", + "report": "reports/2026-08-03.md" + }, + { + "date": "2026-08-03", + "slug": "offline-door-policy-update", + "persona": "building-access systems developer, Rust beginner with production embedded-C experience", + "domain": "offline physical-access policy distribution", + "constraints": [ + "fixed 16 MiB policy image and 4 MiB working-memory target", + "signed duplicated reordered skipped or truncated bundles", + "50,000 emergency revocations with monotonic versions", + "old-or-new recovery after every modeled 4 KiB flash write" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": ["fold"], + "outcome": "no_fit", + "report": "reports/2026-08-03.md" + }, + { + "date": "2026-08-04", + "slug": "mixed-version-contract-gate", + "persona": "deployment-platform developer, intermediate Rust with production Go experience", + "domain": "mixed-version event-contract compatibility during rolling deployment", + "constraints": [ + "300 services, 120 topics, 1,800 contracts, and 12,000 relationships", + "every permitted producer-consumer version pair evaluated deterministically", + "unsupported or malformed schema input must require review", + "five-second and 128 MiB one-process targets" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-04.md" + }, + { + "date": "2026-08-04", + "slug": "ocr-redaction-remap", + "persona": "public-records processing engineer, Rust beginner with production Python experience", + "domain": "OCR redaction remapping and publication safety", + "constraints": [ + "5,000 page-streaming workload with 20 million scalars and 150,000 reviewed spans", + "ambiguous correspondence must conservatively cover or block", + "diagnostics must not contain OCR or matched text", + "controlled process resume and 64 MiB measured-memory target" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-04.md" + }, + { + "date": "2026-08-05", + "slug": "fastq-barcode-spill", + "persona": "sequencing-pipeline engineer, intermediate Rust with production Python experience", + "domain": "streaming genomic demultiplexing and bounded output fan-out", + "constraints": [ + "one non-seekable pass over 1,000,000 paired FASTQ records", + "unique Hamming-distance-one correction with ambiguous ties", + "at most 24 sample output files open", + "no read or sample content in diagnostics and completion manifest only after validation" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-05.md" + }, + { + "date": "2026-08-05", + "slug": "parcel-delta-tiles", + "persona": "civic GIS platform developer, Rust beginner with production TypeScript experience", + "domain": "parcel geometry delta planning for map-tile invalidation", + "constraints": [ + "authoritative old and new Polygon or MultiPolygon deltas without parcel-store access", + "closed-boundary tile contact with strict simple-topology admission", + "deterministic lexicographic plan and no partial output on invalid input", + "1,000 mixed edits with 200 vertices per line under five seconds and 256 MiB" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-05.md" + }, + { + "date": "2026-08-06", + "slug": "caldav-recurrence", + "persona": "CalDAV calendar maintainer, beginner-to-intermediate Rust", + "domain": "calendar recurrence and time-zone correctness", + "constraints": [ + "SQLite remains authoritative", + "timed and all-day recurrence across DST gaps and folds", + "canonical overrides, deterministic ordering, and atomic publication", + "5,000 masters and 2,000,000 candidate occurrences" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": ["fold"], + "outcome": "no_fit", + "report": "reports/2026-08-06.md" + }, + { + "date": "2026-08-06", + "slug": "http-cache-revalidation", + "persona": "edge reverse-proxy maintainer, intermediate Rust", + "domain": "HTTP cache revalidation and tag-purge safety", + "constraints": [ + "separate metadata and content-addressed body authority", + "Vary-aware identity, freshness, stale-if-error, and single-flight revalidation", + "ordered tenant tag purges and modeled crash recovery", + "2,000,000 objects, 1,000,000 requests, and 100,000 purges" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-06.md" + }, + { + "date": "2026-08-07", + "slug": "localization-catalog-compiler", + "persona": "desktop build-tooling developer, intermediate Rust", + "domain": "localization catalog validation and deterministic compilation", + "constraints": [ + "100,000 messages across 18 locales with nested fallbacks", + "missing plural branches, placeholder mismatches, invalid references, and duplicate IDs", + "offline CI with a 512 MiB memory limit and three-minute budget", + "complete source diagnostics and byte-for-byte deterministic runtime tables" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "local_proof_only_no_production_fit", + "report": "reports/2026-08-07.md" + }, + { + "date": "2026-08-07", + "slug": "webhook-scheduler", + "persona": "multi-tenant event-delivery backend developer, senior Rust", + "domain": "webhook delivery scheduling and external side-effect coordination", + "constraints": [ + "200,000 events per hour across 10,000 tenants and 500 endpoints", + "per-endpoint ordering, tenant and endpoint rate limits, and bounded noisy-endpoint queues", + "at-least-once delivery across crashes with deterministic retry and dead-letter classification", + "fair recovery from a one-hour endpoint outage without retry storms" + ], + "components_considered": ["fold", "ese", "anny"], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-07.md" + }, + { + "date": "2026-08-08", + "slug": "remittance-reconciliation", + "persona": "revenue-cycle platform engineer, Rust beginner with production Java and SQL experience", + "domain": "medical remittance reconciliation and exact constrained assignment", + "constraints": [ + "62,000 claim revisions and 50,000 remittance lines in one immutable nightly snapshot", + "exact integer-cent conservation with no silent double posting", + "deterministic output across ten input shuffles and complete uncertainty review", + "60-second four-core target with privacy-safe explanations" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-08.md" + }, + { + "date": "2026-08-08", + "slug": "container-yard-planner", + "persona": "terminal-operating-system integration developer, Rust novice with production C# and SQL experience", + "domain": "advisory container-yard constraint planning", + "constraints": [ + "48 bays by 6 rows with 1,050 to 1,300 containers and 40 to 120 ordered pickups", + "every intermediate move must satisfy capacity, weight, reefer, customs, maintenance, and hazardous-neighbor rules", + "deterministic replayable output with no executable partial plan", + "10-second target and at least 20 percent fewer relocations than nearest-legal-slot baseline" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-08.md" + }, + { + "date": "2026-08-09", + "slug": "incremental-syntax-highlighting", + "persona": "desktop editor maintainer, three years of production Rust experience", + "domain": "incremental UTF-8 syntax highlighting and editor tooling", + "constraints": [ + "exact equality with the authoritative full lexer after every valid edit", + "10 MiB and 200,000-line document with 2,000 deterministic edits", + "UTF-8 byte-coordinate safety and unchanged state after invalid edits", + "64 MiB incremental-index target and byte-for-byte reproducibility" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-09.md" + }, + { + "date": "2026-08-09", + "slug": "firewall-policy-impact", + "persona": "network release-tooling engineer, Rust beginner with production Go experience", + "domain": "exact ordered firewall policy impact analysis", + "constraints": [ + "exact first-match change regions and proposed-rule reachability", + "IPv4 and IPv6 policies with up to 50,000 ordered rules", + "deterministic replayable witnesses and no partial or stale verdict", + "15-second and 256 MiB target on the declared local machine" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-09.md" + }, + { + "date": "2026-08-10", + "slug": "support-case-finder", + "persona": "customer-support platform developer, production TypeScript expert with six months of Rust experience", + "domain": "bilingual advisory duplicate-case retrieval beside authoritative PostgreSQL", + "constraints": [ + "75,000 privacy-scrubbed English and Spanish cases with 2,000 daily changes", + "at least 72 percent recall at five on a private 200-query set and deterministic exact source references", + "2-core and 2 GiB CPU-only container with no query-time network access", + "failed refresh preserves the prior complete replaceable local index" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [ + "fold", + "ese", + "anny" + ], + "outcome": "local_proof_only_no_production_fit", + "report": "reports/2026-08-10.md" + }, + { + "date": "2026-08-10", + "slug": "repair-cafe-kiosk", + "persona": "repair-cafe volunteer developer, strong Python and SQL with intermediate Rust experience", + "domain": "offline single-writer tool-lending ledger and current inventory", + "constraints": [ + "8,000 items, 1,200 borrowers, and 100,000 ordered historical events", + "atomic accepted-event and current-state updates with exact replay", + "fully offline single local data directory and 256 MiB process limit", + "embedded SQL baseline with familiar constraints, inspection, backup, and recovery" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [ + "fold" + ], + "outcome": "no_fit", + "report": "reports/2026-08-10.md" + }, + { + "date": "2026-08-11", + "slug": "cold-chain-repair", + "persona": "regional food-distributor platform engineer with six years of Python and one year of Rust", + "domain": "cold-chain incident-state repair from an authoritative NDJSON archive", + "constraints": [ + "400 freezers and one million observations under 60 seconds and 384 MiB", + "exact event-time results under duplicates, late uploads, corrections, and backdated configurations", + "byte-identical canonical explanations across batch boundaries and retries", + "authoritative append-only archive with disposable derived state and tested process-exit boundaries" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [ + "fold" + ], + "outcome": "no_fit", + "report": "reports/2026-08-11.md" + }, + { + "date": "2026-08-11", + "slug": "receiving-slot-admission", + "persona": "warehouse-management backend engineer with eight years of TypeScript and PostgreSQL and three months of Rust", + "domain": "transactional receiving-dock slot admission across replicated services", + "constraints": [ + "PostgreSQL remains authoritative for command state and audit in one transaction", + "six replicas, four-worker collision trials, and deterministic idempotent outcomes", + "hold, confirm, cancel, reschedule, and expiry decisions use injected database time", + "100000-command target of at least 250 per second and p95 below 40 ms on disposable PostgreSQL" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [ + "fold" + ], + "outcome": "no_fit", + "report": "reports/2026-08-11.md" + }, + { + "date": "2026-08-12", + "slug": "dns-change-gate", + "persona": "hosting-platform reliability engineer with seven years of Go and shell and four months of Rust", + "domain": "offline authoritative-DNS change admission over immutable master-file snapshots", + "constraints": [ + "named-checkzone remains authoritative and the prototype is advisory only", + "10,000 zones and two million records per snapshot under a four-core Linux and 256 MiB target", + "exact deterministic parsing, comparison, policy evidence, and fail-closed expansion limits", + "contained immutable input trees and atomic complete-report publication" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-12.md" + }, + { + "date": "2026-08-12", + "slug": "midi-scheduler-rt-model", + "persona": "production C++ audio developer with Rust Book experience and no shipped Rust callback", + "domain": "hard-real-time MIDI event scheduling for a desktop sequencer", + "constraints": [ + "200,000 events, 50,000 tempo nodes, exact 960 PPQN conversion, and variable callback blocks", + "zero callback allocation, locks, system calls, logging, or panic with fixed-capacity overload handling", + "deterministic transport safety, duplicate-token rejection, and off-thread plan publication", + "10 million callback latency target with C++ retained unless every production safety gate is proved" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-12.md" + }, + { + "date": "2026-08-13", + "slug": "multi-carrier-return-refund-reconciler", + "persona": "commerce-platform backend developer with seven years of TypeScript and PostgreSQL and four months of Rust", + "domain": "advisory multi-carrier return and refund reconciliation over an authoritative PostgreSQL snapshot", + "constraints": [ + "25,000 returns, 40,000 parcels, 70,000 authorized lines, and 250,000 source events", + "exact integer-unit and integer-cent conservation with no automatic payment side effect", + "duplicate, conflicting, corrected, split-parcel, substituted-item, and ambiguous payment evidence", + "byte-identical complete-report publication with a 60-second and 512 MiB local target" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-13.md" + }, + { + "date": "2026-08-13", + "slug": "municipal-water-meter-billing-repair", + "persona": "municipal-utility data engineer with nine years of SQL and Python and one year of Rust", + "domain": "offline water-meter billing repair from authoritative cumulative-reading exports", + "constraints": [ + "100,000 service points, 120,000 meters, two million readings, and 200,000 billed intervals", + "exact register rollover, meter replacement, supersession, provenance, and adjustment-only output", + "input-order and batch independence with per-service fail-closed ambiguity", + "complete prior-report preservation across validation failure and real process exits" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-13.md" + }, + { + "date": "2026-08-14", + "slug": "crash-safe-undo-history", + "persona": "vector-editor document-core maintainer with eight years of Swift and C++ and six months of Rust", + "domain": "crash-safe branching undo history for a local vector editor", + "constraints": [ + "60,000 objects and 250,000 submitted actions with grouped edits, undo, redo, branching, and exact retry identity", + "canonical JSON remains the interchange format while acknowledged groups survive process exit", + "single writer with four read-only inspectors and a 20,000-group reversible compaction window", + "90-second, 256 MiB memory, 512 MiB storage, four-millisecond p95, and five-second reopen targets" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [ + "fold" + ], + "outcome": "local_proof_only_no_production_fit", + "report": "reports/2026-08-14.md" + }, + { + "date": "2026-08-14", + "slug": "epoch-safe-mailbox-mirror", + "persona": "desktop mail-sync maintainer with twelve years of Kotlin and SQLite and three months of Rust", + "domain": "epoch-safe disposable mailbox projection beside a remote authority", + "constraints": [ + "80 mailboxes, 300,000 messages, and 600,000 decoded responses with duplicates and interrupted batches", + "exact UID-validity epoch isolation, stable identity lifecycle, cursor monotonicity, and atomic old-or-new visibility", + "one writer, eight concurrent readers, 384 MiB memory, and storage within 1.5 times SQLite", + "75-second processing, ten-millisecond count p95, five-second reopen, and deterministic recovery targets" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [ + "fold" + ], + "outcome": "no_fit", + "report": "reports/2026-08-14.md" + }, + { + "date": "2026-08-15", + "slug": "incremental-calculation-cache", + "persona": "financial-planning document-core maintainer with nine years of C# and eight months of Rust", + "domain": "offline incremental calculation cache for a large financial-planning workbook", + "constraints": [ + "append-only authoritative journal with exact signed 128-bit formula and error semantics", + "one million populated cells and 2.4 million dependency edges across 160 worksheets", + "one writer and twelve readers with complete-generation visibility and deterministic snapshots", + "strict crash, disk, memory, initial-build, reopen, and batch-latency gates" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [ + "fold" + ], + "outcome": "no_fit", + "report": "reports/2026-08-15.md" + }, + { + "date": "2026-08-15", + "slug": "revision-safe-aircraft-manual-search", + "persona": "aircraft-maintenance tablet search maintainer with eleven years of Kotlin and four months of Rust", + "domain": "revision- and applicability-safe offline aircraft-maintenance manual retrieval", + "constraints": [ + "2.4 million revisions with hard query-specific aircraft and effective-time eligibility", + "exact source evidence and zero ineligible or superseded results", + "sixteen readers during atomic offline generation updates with fixed reproducible model assets", + "strict memory, disk, build, latency, crash, determinism, and privacy gates" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [ + "fold" + ], + "outcome": "no_fit", + "report": "reports/2026-08-15.md" + }, + { + "date": "2026-08-16", + "slug": "causal-canvas-compaction", + "persona": "collaborative-canvas synchronization maintainer with seven years of TypeScript and Go and nine months of Rust", + "domain": "causal multi-writer canvas-history compaction and replay", + "constraints": [ + "PostgreSQL remains authoritative while existing operation IDs, merge rules, and canonical JSON remain unchanged", + "five million operations across 100 documents with duplicates, causal reordering, and 30-day offline clients", + "complete-generation visibility, deterministic replay, bounded untrusted input, and exact conflicting-ID rejection", + "two CPU cores, one GiB memory, 40 percent retained-byte ceiling, and strict latency and reopen gates" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [ + "fold" + ], + "outcome": "no_fit", + "report": "reports/2026-08-16.md" + }, + { + "date": "2026-08-16", + "slug": "transparency-checkpoint-verifier", + "persona": "certificate-transparency monitor maintainer with eight years of Go and security operations and four months of Rust", + "domain": "offline cryptographic transparency-checkpoint verification and crash-safe advancement", + "constraints": [ + "trusted Go and SQLite baseline with exact signed-checkpoint, proof, cursor, and decision semantics", + "eight million leaf hashes, 4,000 checkpoints, bounded envelopes, and 10,000 labeled adversarial cases", + "fail-closed verification, deterministic pending advancement, auditable raw proof bytes, and old-or-new publication", + "two CPU cores, 256 MiB memory, no network, and strict replay, p95, reopen, and malformed-input gates" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-16.md" + }, + { + "date": "2026-08-17", + "slug": "corruption-resynchronizing-instrument-stream-decoder", + "persona": "laboratory gateway developer with nine years of C and C++ and two small Rust command-line tools", + "domain": "corruption-resynchronizing binary instrument stream decoding", + "constraints": [ + "one million valid frames across 64 interleaved streams plus 25,000 deterministic damage cases", + "opaque payloads, exact marker-length-CRC proof, and immediate known-valid follower recovery", + "64 MiB process-memory ceiling, 8,192-byte per-stream retention bound, and no blocking I/O in consumption", + "chunk-independent deterministic output with 100,000 arbitrary-byte stress cases and strict throughput floors" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-17.md" + }, + { + "date": "2026-08-17", + "slug": "mergeable-unique-installation-reach-rollups", + "persona": "analytics-infrastructure engineer with eight years of Kotlin and SQL and four months of Rust", + "domain": "bounded-memory mergeable unique-installation reach rollups", + "constraints": [ + "12 million immutable records across 5,760 tenant-hour buckets and eight independently aggregated shards", + "2,880-observation accuracy matrix plus separately retained held-out evidence", + "portable byte-identical shard merge, duplicate idempotence, canonical output, and complete-file integrity", + "128 MiB candidate-memory ceiling, four-times exact-baseline reduction, and 1.5-times runtime ceiling" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-17.md" + }, + { + "date": "2026-08-18", + "slug": "laboratory-unit-conversion-gate", + "persona": "healthcare-interface developer with eight years of Java and SQL and four months of Rust", + "domain": "exact laboratory-unit conversion admission", + "constraints": [ + "authoritative PostgreSQL and Java conversion behavior with exact rational affine transforms and round-half-to-even", + "one million observations, 12,000 mappings, 20,000 generated arithmetic cases, and five full input shuffles", + "complete reference validation, bounded lines, stable privacy-safe reasons, and atomic canonical report publication", + "two CPU cores, 256 MiB memory, 30-second full-run target, and same-host Java comparison" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-18.md" + }, + { + "date": "2026-08-18", + "slug": "freight-capacity-batch-clearing", + "persona": "freight-marketplace backend developer with six years of Ruby and PostgreSQL and five months of Rust", + "domain": "deterministic freight-capacity marketplace batch clearing", + "constraints": [ + "PostgreSQL remains authoritative while advisory price-time clearing must ignore input order", + "650,000 orders across 5,000 markets with checked conservation, gross value, and a 645,000-fill cap", + "ten full shuffles, exact slow-reference comparison, bounded line reading, and atomic canonical proposal publication", + "two CPU cores, 512 MiB memory, 20-second full-run target, and same-host Ruby median comparison" + ], + "components_considered": [ + "fold", + "ese", + "anny" + ], + "components_used": [], + "outcome": "no_fit", + "report": "reports/2026-08-18.md" + } + ], + "recurring_findings": [ + { + "id": "document-storage-and-concurrency-boundaries", + "category": "documentation gap", + "independent_trial_count": 38, + "confidence": "high", + "candidate": true, + "summary": "The public onboarding should distinguish embedded source-of-truth use from external databases and calendar or cache body-store handoffs, external network authorities and cooperative filesystem protocols, horizontal or partitioned deployment, durable leases and ordered purges, strict disk quotas, raw-flash firmware and fixed controller images, atomic broker-offset ownership, exact recursive graph workloads, bounded stateless stream or geometry transformations, exact synchronous protocol framing, immutable externally mergeable partial-state batch rollups, exact numeric admission transforms, and deterministic one-shot market clearing that do not benefit from durable state.", + "smallest_improvement": "Add a concise component, storage, transaction, concurrency, operating-system, and hard-limit capability matrix.", + "sources": [ + "purchase-audit", + "offline-reconciliation", + "edge-spool-pressure", + "flash-config-journal", + "ci-lease-coordinator", + "fraud-velocity-evaluation", + "homecare-gap-fill", + "parts-catalog-evolution", + "offline-flag-parity", + "cnc-job-bundle-preflight", + "carrier-label-ambiguity", + "snapshot-gc-safety", + "provenance-revocation-impact", + "offline-door-policy-update", + "mixed-version-contract-gate", + "ocr-redaction-remap", + "fastq-barcode-spill", + "parcel-delta-tiles", + "caldav-recurrence", + "http-cache-revalidation", + "support-case-finder", + "repair-cafe-kiosk", + "cold-chain-repair", + "receiving-slot-admission", + "dns-change-gate", + "midi-scheduler-rt-model", + "multi-carrier-return-refund-reconciler", + "municipal-water-meter-billing-repair", + "crash-safe-undo-history", + "epoch-safe-mailbox-mirror", + "incremental-calculation-cache", + "revision-safe-aircraft-manual-search", + "causal-canvas-compaction", + "transparency-checkpoint-verifier", + "corruption-resynchronizing-instrument-stream-decoder", + "mergeable-unique-installation-reach-rollups", + "laboratory-unit-conversion-gate", + "freight-capacity-batch-clearing" + ] + }, + { + "id": "component-selective-project-scaffold", + "category": "API friction", + "independent_trial_count": 16, + "confidence": "high", + "candidate": true, + "summary": "Developers with Fold-only or no-component scenarios avoid the all-components starter or project scaffold because it adds unrelated crates and setup.", + "smallest_improvement": "Make project generation component-selective and keep the smallest Fold introduction free of unused ESE and ANNy dependencies.", + "sources": [ + "purchase-audit", + "edge-spool-pressure", + "flash-config-journal", + "ci-lease-coordinator", + "mixed-version-contract-gate", + "ocr-redaction-remap", + "fastq-barcode-spill", + "parcel-delta-tiles", + "caldav-recurrence", + "http-cache-revalidation", + "repair-cafe-kiosk", + "receiving-slot-admission", + "dns-change-gate", + "midi-scheduler-rt-model", + "crash-safe-undo-history", + "incremental-calculation-cache" + ] + }, + { + "id": "nameable-pipeline-and-reader-types", + "category": "API friction", + "independent_trial_count": 7, + "confidence": "high", + "candidate": true, + "summary": "Developers found composed pipeline and reader types difficult to name in ordinary reusable structs and helper signatures.", + "smallest_improvement": "Document type-alias, function-pointer, and ordinary helper-signature patterns for composed pipelines and readers; do not infer a new type-erasure subsystem.", + "sources": [ + "ci-lease-coordinator", + "fraud-velocity-evaluation", + "homecare-gap-fill", + "support-case-finder", + "repair-cafe-kiosk", + "cold-chain-repair", + "receiving-slot-admission" + ] + }, + { + "id": "fold-wtx-panic-writer-poisoning", + "category": "correctness defect", + "independent_trial_count": 2, + "confidence": "high", + "candidate": true, + "summary": "Catching a panic resumed by Fold's write transaction preserves committed data but poisons Fjall's single-writer lock, so every later write in the same process panics.", + "smallest_improvement": "Drop the underlying write transaction before resuming the user panic, then add Stream and KeyedStream regressions that prove rollback and a successful later write.", + "sources": [ + "support-case-finder", + "epoch-safe-mailbox-mirror" + ] + }, + { + "id": "fold-persisted-value-codec-panic", + "category": "correctness defect", + "independent_trial_count": 1, + "confidence": "high", + "candidate": true, + "summary": "Fold's public Serde bounds accept persisted value shapes that Postcard cannot deserialize, while internal unwraps turn the predictable incompatibility into a deterministic read-time panic after bytes have committed.", + "smallest_improvement": "Document Postcard-compatible Serde shapes at every persisted-value entry point and replace serialization and deserialization unwraps with recoverable typed errors where an API-compatible path exists.", + "sources": [ + "cold-chain-repair" + ] + }, + { + "id": "fold-fallible-storage-and-decode-apis", + "category": "API friction", + "independent_trial_count": 2, + "confidence": "high", + "candidate": true, + "summary": "Failure-aware consumers cannot handle several Fold open, keyspace, read, decode, commit, and checkpoint failures as typed errors because the convenience-only public paths panic.", + "smallest_improvement": "Add documented fallible variants for the existing storage and decoding boundaries while retaining convenience wrappers where useful.", + "sources": [ + "causal-canvas-compaction", + "transparency-checkpoint-verifier" + ] + }, + { + "id": "query-time-filtered-search", + "category": "missing capability", + "independent_trial_count": 2, + "confidence": "high", + "candidate": true, + "summary": "BM25 and HNSW expose global bounded candidate readers without a query-time predicate or allowed-key set, so post-filtering can lose the only eligible result below the candidate cutoff.", + "smallest_improvement": "First document that BM25 and HNSW return global candidates and that bounded post-filtering can lose eligible records; then evaluate a narrow allowed-key or predicate design without inferring a general search subsystem.", + "sources": [ + "support-case-finder", + "revision-safe-aircraft-manual-search" + ] + }, + { + "id": "ese-hermetic-model-assets", + "category": "documentation gap", + "independent_trial_count": 2, + "confidence": "high", + "candidate": true, + "summary": "Clean ESE builds fetch mutable model and tokenizer assets without documented identity, checksum verification, cache placement, language scope, or a preseeded offline workflow.", + "smallest_improvement": "Document model identity, language scope, cache location, and first-build network behavior; add a preseeded offline recipe and pin immutable asset revisions with checksum verification before considering model-selection APIs.", + "sources": [ + "support-case-finder", + "revision-safe-aircraft-manual-search" + ] + } + ] +} diff --git a/developer-simulation/reports/2026-07-28.md b/developer-simulation/reports/2026-07-28.md new file mode 100644 index 0000000..99fe2c6 --- /dev/null +++ b/developer-simulation/reports/2026-07-28.md @@ -0,0 +1,180 @@ +# Developer simulation — 2026-07-28 + + + +The inaugural run tested two practical systems selected before the scenario +designer saw BogKit. Neither justified adding BogKit to the stated production +architecture. That negative result is useful: both trials found a repeated +onboarding gap around embedded storage, transaction boundaries, and +single-writer deployment. + +## Trial 1 — Purchase-approval audit timeline + +- Persona: finance software developer with three years of Rust experience +- Existing system: Rust and PostgreSQL purchase-request service +- Problem: create a role-protected seven-year audit history in the same + transaction as each state mutation +- Outcome: no fit +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold, only to reproduce the transaction boundary +- Archive: + [`runs/2026-07-28--purchase-audit`](../runs/2026-07-28--purchase-audit) + +### Evidence + +- Eight tests passed, including a split-store partial-commit reproduction and + a retraction reproduction using real Fold. +- Strict lint and source formatting checks passed. +- The runnable comparison showed approved current state with only the older + create event after failure between the two stores. +- The local in-memory baseline returned 10,000 events in 651–889 microseconds + across observed runs. This does not test PostgreSQL. + +### Friction trail + +The simulator started with the root README and starter example. The starter +unexpectedly tried to build unused ESE and ANNy dependencies and download the +embedding model. Source inspection then showed that Fold owns an embedded Fjall +transaction; the chat example's atomic design makes Fold the source of truth. +That cannot satisfy a mandatory PostgreSQL transaction boundary. + +### Findings + +1. **Poor product fit — critical, high confidence.** Fold cannot atomically + join the required PostgreSQL mutation, and replacing PostgreSQL is out of + scope. Smallest improvement: document the boundary; do not infer a new + PostgreSQL sink from one trial. +2. **Scenario mismatch — high, high confidence.** A retractable `Bag` is not a + role-protected append-only audit table. This is intentional Fold behavior, + not a security defect. +3. **Documentation gap — medium, high confidence.** The top-level description + does not surface embedded storage, external transaction limits, or the + single-writer architecture early. +4. **Onboarding friction — medium, high confidence.** The starter declares ESE + and ANNy although its source uses only Fold, making the smallest example + depend on an embedding download. + +### Decision audit + +The PostgreSQL audit-table baseline was selected because it alone shares the +required native transaction and role model. A Fold sidecar, Fold as source of +truth, and a PostgreSQL outbox feeding Fold were rejected as partial-commit, +requirement, or needless-duplication risks. ESE and ANNy were not applicable. + +## Trial 2 — Warehouse scanner offline reconciliation + +- Persona: warehouse device developer with six months of Rust experience +- Existing system: offline SQLite scanners uploading current rows to a + Rust/PostgreSQL API +- Problem: preserve duplicate, reordered, interrupted, and conflicting + operations without silent location loss +- Outcome: useful local proof, no demonstrated BogKit advantage, no production + fit +- BogKit components considered and used: Fold +- Archive: + [`runs/2026-07-28--offline-reconciliation`](../runs/2026-07-28--offline-reconciliation) + +### Evidence + +- Seven tests passed: exact replay, divergent identity rejection, 128 shuffled + orders, interrupted retry, conflict visibility, checkpointed reopen, and a + 20,000-operation threshold. +- Strict lint and source formatting checks passed. +- The demo exposed both conflicting locations and survived the synthetic + interruption after reopening. +- Three release observations processed 20,000 synthetic operations in 54, 57, + and 76 milliseconds, all below five seconds. +- No Fjall, SQLite, or PostgreSQL control was built. The snapshot scans and + recomputes all stored operations, so this does not demonstrate incremental + materialization value. + +### Friction trail + +The simulator read the public examples in order. Chat revealed single-owner +writes; search revealed keyed replacement. The first online build failed DNS +and succeeded offline. A caught-panic interruption rolled back state but +printed a panic diagnostic and required reopening the writer. Strict lint then +found and helped reduce an oversized diagnostic type. + +### Findings + +1. **Scenario data gap — critical, high confidence.** Device-local sequence and + timestamps cannot establish cross-device causality. The operation schema + needs a base revision or observed heads. +2. **Poor product fit — high, high confidence.** The required source of truth + is horizontally deployed PostgreSQL; the proof uses an embedded + single-writer store. +3. **Usage constraint — informational, high confidence.** Keyed upsert replaces + an existing value by design. The prototype safely implements + compare-and-reject with the existing transaction API; no new API is + proposed. +4. **Test-harness caveat — informational, high confidence.** The synthetic + caught panic prints a diagnostic and reopens the store. It is not evidence + for a new recovery API. +5. **Documentation gap — medium, high confidence.** The onboarding does not + quickly distinguish component, storage, and concurrency boundaries. + +### Decision audit + +The proof selected immutable identities, a conservative conflict frontier, and +Fold as a convenient embedded transaction store. It rejected timestamp +ordering and rejected Fold as the production source of truth. The evaluator +softened the original “partial fit” claim because the proof did not compare a +simpler store or use incremental projections. + +## Skeptical review + +- Claims reproduced: both transaction-boundary tests, all fifteen trial tests, + conflict output, strict lint, demos, and release threshold. +- Claims rejected or softened: + - intentional retraction and serialized ordering are scenario mismatches, not + Fold defects; + - keyed replacement is a documented usage constraint, not high-severity API + friction; + - caught-panic reopen behavior is a harness caveat; + - the reconciliation proof shows no BogKit-specific performance or + incremental advantage. +- Bloat rejected: no append-log terminal, insert-if-absent API, PostgreSQL + sink, or panic-recovery API is promoted from these trials. +- Remaining uncertainty: no real PostgreSQL, crash, multi-process, permission, + concurrency, or production query-plan test was performed. + +## Cross-run synthesis + +### Candidate improvement + +Both independent trials had to inspect implementation or example internals to +understand the same boundary. Add a concise public capability matrix covering: + +- embedded source-of-truth versus external database integration; +- the transaction boundary; +- the single-writer deployment shape; +- which problems Fold, ESE, and ANNy each address. + +This is the only candidate promoted today because it met the two-trial +threshold. + +### Observations not yet promoted + +- Remove unused ESE and ANNy dependencies from the starter example so the + smallest Fold introduction does not require the embedding model. +- An immutable-event example could teach caller-side identity checks, but one + run is insufficient evidence for a new API. + +### No-fit and positioning signals + +Both scenarios kept PostgreSQL for fixed operational reasons. BogKit was +strongest only when allowed to own embedded state. Public positioning should +make that intended boundary easy to recognize so developers can select or +reject the toolkit quickly. + +## Validation + +- Trial tests: 15 passed, 0 failed +- Strict lint: passed with warnings denied +- Formatting: all four archived Rust source files passed `rustfmt --check` +- Runnable demonstrations: passed +- Release threshold: 20,000 operations completed in 76 ms on final verification +- BogKit root workspace tests: 45 unit and documentation tests passed +- Generated archive check: no build output, databases, secrets, or binary + fixtures staged diff --git a/developer-simulation/reports/2026-07-29.md b/developer-simulation/reports/2026-07-29.md new file mode 100644 index 0000000..2a01765 --- /dev/null +++ b/developer-simulation/reports/2026-07-29.md @@ -0,0 +1,313 @@ +# Developer simulation — 2026-07-29 + + + +Two blind trials tested BogKit against operational limits that had not appeared +in the archive: a strictly bounded edge-telemetry spool and raw-NOR firmware +configuration storage. Both reached evidence-backed no-fit conclusions. The +edge trial found useful Fold behavior before a hard disk-bound requirement +failed. The firmware trial rejected BogKit before integration because its +filesystem and allocation model does not match raw flash. + +## Trial 1 — Edge telemetry spool under disk pressure + +- Persona: site reliability engineer with intermediate Rust experience +- Existing system: newline-delimited JSON files, whole-file retries, and + oldest-file deletion above a 256 MiB spool limit +- Problem: preserve high-priority events, explain duplicates and drops, and + recover after process interruption without exceeding a hard allocated-disk + bound +- Outcome: no fit for the strict disk bound +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold +- Archive: + [`runs/2026-07-29--edge-spool-pressure`](../runs/2026-07-29--edge-spool-pressure) + +### Evidence + +- Commands: + - `cargo test -p edge-spool-pressure --all-targets --offline` + - `cargo clippy -p edge-spool-pressure --all-targets --offline -- -D warnings` + - `cargo run -p edge-spool-pressure --release --offline -- demo` +- Tests: 10 passed, 0 failed. +- Demonstration: + - The deterministic baseline modeled 1,000,000 events. It retained 770,212 + and deleted 6,894 critical events under age-only deletion. + - The representative Fold run processed 20,000 events. It retained all 600 + critical and 2,400 operational events, and durably accounted for every + debug drop in that run. + - After an interrupted write, the reopened store contained exactly the + pre-transaction event IDs. + - After an interrupted upload, all 120 retained IDs remained. The durable + intent reported 25 possible duplicates; the mock collector observed 7 + actual duplicates after retry. + - Reopen and inspection took 4 ms on the host. +- Baseline comparison: + - The newline-file model exposes one file-sized retry window of up to 12,064 + events and cannot preserve critical traffic under age-only deletion. + - The Fold proof improved priority retention and duplicate explanation. + - It did not satisfy the hard disk requirement. A 1 MiB logical limit retained + 1,048,512 logical bytes while the database allocated 3,305,472 bytes. + - The probe proves that logical accounting is not a physical cap and that the + evaluated public interface has no documented hard-cap guarantee. It does + not predict exact allocation at 256 MiB or test external filesystem quotas. + +### Friction trail + +The simulator began with the root README, then read the starter, time-series, +chat, and search examples. It avoided the project generator because the +scenario needed Fold but not ESE or ANNy. Fold's transactions, Bag ordering, +aggregates, and durable state fit the accounting problem. Time-based retention +did not express byte pressure or priority. + +The first online build failed to resolve the crates index and later succeeded +with network access. A first implementation placed a complete Bag consistency +scan on the write path; the simulator removed that diagnostic work before final +measurement. Apparent file lengths were also misleading because the database +uses sparse or preallocated files, so the final quota decision used allocated +Unix blocks and was independently confirmed with `du`. + +### Findings + +1. **Baseline correctness defect — high severity, high confidence.** The + supplied age-only file policy deleted 6,894 modeled critical events. + Reproduction: run the `baseline` command. Smallest improvement: separate + priority budgets and record every eviction. +2. **Baseline correctness defect — high severity, high confidence.** A + whole-file retry exposes the acknowledged prefix of a modeled 12,064-event + file to duplication without a durable intent or offset. This is not a + BogKit defect. Smallest improvement: persist the attempted batch or offset + before sending. +3. **Documentation gap — medium severity, high confidence.** Public onboarding + does not state hard allocated-byte limits, compaction headroom, process + crash versus power-loss guarantees, or memory and thread bounds. + Reproduction: start at the README and examples, then build the quota probe. + Smallest improvement: document storage limits and durability boundaries. +4. **Missing scenario capability — critical severity, high confidence for the + public guarantee gap.** The public interface has no documented hard + allocated-byte cap. Confidence is only medium for exact 256 MiB behavior + because that scale and external quotas were not tested. Smallest + improvement: document the unsupported boundary; add store-level controls + only if strict quotas are intended. +5. **Prototype limitation — informational, high confidence.** The candidate + stage demonstrated roughly 2,200–2,500 events per second with its checkpoint + and verification work, below the 5,000-events-per-second burst in the brief. + Existing `Ranked` range scans were not evaluated, so the reviewer rejected + the original general performance claim and new range-scan proposal. +6. **One-off API observation — informational, medium confidence.** The + prototype stores full events in upload intent so `Stream::remove` can retract + them. `KeyedStream` removes by key, and every durable intent needs some batch + representation. No API change is promoted. +7. **Poor product fit — critical if selected, high confidence.** Useful + transaction and recovery evidence does not overcome the missing hard-cap + guarantee. Use a storage engine designed around a fixed ring or segment + budget. + +### Decision audit + +The simulator selected `Stream` and Bag to keep retained payloads in one +iterable durable view, put priority first in serialization for deterministic +ordering, and committed eviction with drop accounting. It recorded upload +intent before delivery and cleared it with acknowledgement. + +It rejected time-based retention, count-based top-K retention, direct access to +Fold's internal Fjall store, and a million-event Fold run after the quota +prerequisite failed. The reviewer identified `Ranked` range scans as an +untried alternative, so no range API or general Fold performance conclusion is +retained. + +Unresolved: exact 256 MiB allocation, external quotas, sustained burst ingest, +one-million-event Fold behavior, power loss, commit interruption inside the +storage engine, compaction cycles, background threads, real HTTP buffering, and +schema-order stability. + +## Trial 2 — Power-fail-safe raw-NOR configuration journal + +- Persona: embedded controls developer with beginner-to-intermediate Rust + experience +- Existing system: one CBOR configuration blob and checksum overwritten in + place on 128 KiB of reserved NOR flash +- Problem: keep either the complete old or new 2–24 KiB configuration after + power interruption, with bounded scan, memory, and wear +- Outcome: no fit +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-07-29--flash-config-journal`](../runs/2026-07-29--flash-config-journal) + +### Evidence + +- Commands: + - `cargo test -p flash-config-journal --all-targets --offline -- --nocapture` + - `cargo clippy -p flash-config-journal --all-targets --offline -- -D warnings` + - `cargo run -p flash-config-journal --offline` +- Tests: 5 passed, 0 failed. +- Demonstration: + - The single-slot baseline loses its only valid configuration after a modeled + interrupted erase. + - The circular journal audited all 6,177 modeled write boundaries for one + 2 KiB update and all 53,281 for one 24 KiB update. Every boundary booted the + complete old or complete new revision. + - Deterministic corruption of revision 101 made the file-backed emulator + recover complete revision 100. + - The image is exactly 131,072 bytes and every boot scan reads 32 block + starts. + - A test of 10,000 fixed 2 KiB updates produced 312–313 erases per block, a + 0.32% imbalance. +- Baseline comparison: + - A checksum detects the supplied in-place baseline's damage but cannot + recover the overwritten old value. + - The dependency-free reference journal keeps the active record untouched + until a final one-way commit byte publishes its replacement. + - Fold was rejected because its public entry point opens a filesystem-backed + Fjall database and does not expose raw read/program/erase operations, + `no_std`, fixed whole-stack memory, wear accounting, or a 32-block recovery + bound. + +### Friction trail + +The simulator began with the root README and starter example. The example's +filesystem path established that BogKit persistence targets a host application +database. Fold's crate documentation later described Fjall as “embedded,” but +public onboarding did not define the operating-system, filesystem, allocator, +or firmware boundary near its persistence claims. + +The simulator inspected all public examples and Fold's stream implementation, +then avoided the all-components project scaffold and built a dependency-free +reference journal. Formatting and strict lint each found one ordinary issue +that was fixed before the final run. + +### Findings + +1. **Baseline correctness defect — critical severity, high confidence.** The + supplied in-place single-slot strategy can lose its only valid record after + an interrupted erase. This is not a BogKit defect. Reproduction: run + `baseline_single_blob_has_a_boundary_with_no_valid_configuration`. Smallest + improvement: keep the active copy until the replacement is checked and + committed. +2. **API friction — medium severity under constrained builds, high + confidence.** The project scaffold adds Fold, ESE, ANNy, and Serde even when + a scenario needs fewer or no components. Reproduction: inspect + `scripts/new-project.sh`. Smallest improvement: make generation + component-selective. +3. **Documentation gap — medium severity, high confidence.** Public onboarding + does not state the `std`, filesystem, allocator, and firmware boundaries + near persistence claims. The root README does not claim firmware support. + Smallest improvement: describe Fold as an in-process, filesystem-backed + database for `std` targets and list unsupported firmware constraints. +4. **Missing capability — critical for this scenario, high confidence.** No raw + NOR interface, fixed recovery-I/O bound, `no_std` feature, erase geometry, + wear accounting, or fixed whole-stack memory contract appears in the + inspected public surface. Smallest improvement: document raw flash and + `no_std` as unsupported. A real capability would require a separate storage + engine. +5. **Poor product fit — critical if selected, high confidence.** Fold + incrementally materializes application data in an LSM store. The controller + replaces one bounded opaque blob across 32 known erase blocks. Do not treat a + host filesystem transaction as a raw-NOR transaction. + +### Decision audit + +The simulator selected a whole-record circular journal, variable contiguous +block runs, payload-first/header-second/commit-last publication, CRC-32, +monotonic revisions, and streaming input with a declared final length. + +It rejected the existing single slot, two fixed slots with concentrated wear, +Fold/Fjall, CBOR encoding inside the journal, and extra filesystem or checksum +dependencies. + +The 1,024-byte value in the prototype is an explicit-buffer design budget, not +a measured or mechanically enforced whole-stack bound. Host scan observations +remained below 50 ms but varied across runs and do not prove MCU timing. Wear +coverage is limited to fixed 2 KiB updates. Intermediate-size crash boundaries, +mixed-size wear, real NOR interruption, bad blocks, endurance, rollover, read +disturb, CRC collision, production encoding, and firmware stack use remain +unresolved. + +## Skeptical review + +- Claims reproduced: + - all 10 edge tests, the million-event baseline, both process-exit cases, the + 1 MiB logical-versus-allocated quota gap, full demo, and sampled host RSS; + - all 5 flash tests, the baseline failure, all 59,458 modeled byte boundaries, + the fixed-size wear test, and the file-backed corruption fallback. +- Claims rejected or softened: + - baseline failures are not BogKit defects; + - the 1 MiB edge probe does not predict exact 256 MiB behavior; + - host RSS, recovery, scan, and stack observations are not production or + hardware guarantees; + - the edge linear-scan claim and proposed range API were rejected because + existing `Ranked` scans were not tried; + - flash wear evidence covers fixed 2 KiB updates, not mixed sizes. +- Unnecessary code or dependencies removed: none. Edge uses Fold and Serde; + flash remains dependency-free. The standalone workspace boundary and + pre-archive paths were removed during relocation. +- Remaining uncertainty: production storage, process, throughput, hardware, + and firmware behavior listed in each trial's decision audit. + +## Cross-run synthesis + +### New evidence + +Fold's atomic transactions, durable intent, and persistent views were useful +for a representative edge spool, but they did not provide the fixed allocated +disk guarantee the scenario required. The raw-NOR trial correctly rejected +BogKit before integration. + +### Recurring evidence + +Four independent trials now needed source or example inspection to determine +storage ownership, transaction, deployment, operating-system, or hard-limit +boundaries. + +Three independent trials encountered the all-components starter or project +scaffold while needing only Fold or no BogKit component. + +### Candidate improvements + +1. Expand the existing public capability matrix candidate to cover component + purpose, storage ownership, transaction scope, horizontal deployment, + filesystem and `std` requirements, strict quotas, and raw-flash non-goals. +2. Make the project scaffold component-selective and keep the smallest Fold + introduction free of unused ESE and ANNy dependencies. + +Both meet the independent-trial threshold. Neither implies a new storage +engine or core API. + +### Observations not yet promoted + +- Hard allocated-byte quota controls +- Raw NOR support +- Remove-by-key iteration changes +- New range primitives + +These remain one-off ideas. Existing `Ranked` scans must be evaluated before +any edge range proposal. + +### No-fit and positioning signals + +BogKit is strongest when it may own filesystem-backed application state and +its storage engine's operational envelope is acceptable. A developer should be +able to reject it quickly for external transactions, strict physical quotas, +raw devices, or firmware targets without reading implementation details. + +## Validation + +- Trial-specific tests: 15 passed, 0 failed across the two new prototypes. +- Strict lint and formatting: targeted formatting passed for all four archived + prototypes; the nested workspace passed Clippy with warnings denied. A + repository-wide format check found pre-existing differences in + `examples/search/src/main.rs`; the lab did not modify that file. +- Runnable demonstrations: + - edge full release demo passed in 11.95 seconds on final verification; + - flash file-backed demo reopened revision 101, rejected it after + deterministic corruption, and recovered complete revision 100. +- Nested lab workspace tests: 30 passed, 0 failed. +- BogKit root workspace tests: 33 unit tests and 12 documentation tests passed, + 0 failed. The first offline attempt could not download ESE's required model; + the required network-enabled rerun passed. +- Archive and secret checks: changed paths are confined to + `developer-simulation/`; no generated build output, database, binary, large + fixture, credential pattern, or private key was found in the dated archive. +- Diff and ledger checks: `git diff --check` and `jq empty coverage.json` + passed. diff --git a/developer-simulation/reports/2026-07-30.md b/developer-simulation/reports/2026-07-30.md new file mode 100644 index 0000000..8451ade --- /dev/null +++ b/developer-simulation/reports/2026-07-30.md @@ -0,0 +1,302 @@ +# Developer simulation — 2026-07-30 + + + +Two blind trials tested underexplored distributed-system boundaries: durable CI +job leasing across three active coordinators, and explainable payment-velocity +screening across four partitioned consumers. Both reached reviewed no-fit +conclusions. The prototypes produced useful local evidence, but neither +demonstrated the required replicated authority and stream-ownership model. + +## Trial 1 — Crash-safe CI job leasing + +- Persona: CI platform engineer with intermediate Rust experience +- Existing system: an in-memory build queue with periodic JSON snapshots, + 30-second worker leases, and immutable object-store results +- Problem: preserve dependency readiness, attempt fencing, and explainable + retry decisions across crashes, duplicate messages, and three concurrently + active coordinator replicas +- Outcome: no fit +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold +- Archive: + [`runs/2026-07-30--ci-lease-coordinator`](../runs/2026-07-30--ci-lease-coordinator) + +### Evidence + +- Commands: + - `cargo test -p ci-lease-coordinator --all-targets --offline` + - `cargo clippy -p ci-lease-coordinator --all-targets --offline --no-deps -- -D warnings` + - `cargo run --release -p ci-lease-coordinator --offline -- demo` + - `cargo run --release -p ci-lease-coordinator --offline -- bench /tmp/bogkit-ci-lease-benchmark-2026-07-30.db` +- Tests: 4 passed, 0 failed. +- Demonstration: + - The periodic-snapshot baseline acknowledged worker 7, restarted from the + older snapshot, and gave worker 8 the same attempt-1 fence. + - The Fold reference survived 100 child-process exits immediately after + acknowledged mutations. + - Duplicate and reordered messages were no-ops. + - Heartbeat at the exact deadline and completion far after the deadline were + rejected without changing the lease; the job was then reassigned as + attempt 2 and attempt 1 could not overwrite the winner. + - A second concurrent Fold opener was rejected. This is consistent with an + embedded single-writer store, not a BogKit defect. +- Baseline and bounded measurements: + - A naïve in-place JSON rewrite wrote 20,248,904 bytes in 28–30 ms. It is + only a lower bound, not a crash-safe snapshot protocol, because it does not + write a temporary file, atomically replace the live file, and sync the + directory. + - Three 2,000-heartbeat local passes measured 334,115–348,759 updates/s with + 0.108–0.126 ms p99 assigned batch-commit latency. + - Five local reopen-plus-lease observations were 0.162–0.170 seconds. + - The persistent directory was 16,309,086 apparent bytes for the synthetic + 100,000-job graph. + - These numbers exclude batch formation, networking, scheduling, object + storage, and replica coordination. + +### Friction trail + +The simulator began with the root README and all public examples, then +evaluated the JSON baseline before selecting Fold. Fold transactions and +materialized ready/leased tables fit the single-writer state machine. Naming a +composed pipeline inside an ordinary coordinator struct required explicit type +aliases and function-pointer predicates. + +The first implementation passed its authored tests, but skeptical review found +that a far-late heartbeat could revive an expired lease before the reaper ran. +The coordinator added the same `deadline <= observed coordinator time` boundary +used by the reaper to heartbeat and completion, added exact-deadline and +far-past regression coverage, and reran every check before archival. + +The single-writer boundary appeared only after deeper API inspection and a +concurrent-open probe. The prototype did not build three replica processes: +the public storage contract already lacked the consensus, leader fencing, or +replicated compare-and-swap needed to make that safe. + +### Findings + +1. **Baseline correctness defect — critical severity, high confidence.** + Periodic JSON snapshots can acknowledge a lease that is then forgotten, + allowing two workers to receive the same attempt fence. Reproduction: run + the `baseline` command. Smallest improvement: use a durable authority before + acknowledging the lease. +2. **Poor product fit — critical if selected, high confidence.** Fold's useful + single-writer transaction model does not satisfy three concurrently active + coordinators. Smallest improvement: document the process and replication + boundary; do not infer a new consensus subsystem. +3. **Documentation gap — medium severity, high confidence.** The root guide + and examples do not prominently state the single-writer/process boundary. + Smallest improvement: include it in the public capability matrix. +4. **API friction — medium severity, medium confidence.** Composed pipeline + types are awkward to name in reusable structs. Type aliases and + function-pointer predicates worked. Smallest improvement: document that + pattern before considering type erasure. +5. **Onboarding friction — medium severity, high confidence.** A Fold-only + prototype still reached unrelated ANNy code during dependency-inclusive + strict lint. Smallest improvement: keep the smallest component path + selective and free of unrelated setup where practical. +6. **Prototype correctness defect — fixed before archival.** The original + heartbeat path could revive an expired attempt. This was application logic, + not a BogKit defect. + +### Decision audit + +The trial rejected periodic JSON snapshots because they lose acknowledged +state and treated the measured in-place rewrite only as a lower-bound control, +not a correct replacement. It selected keyed Fold records plus ready and leased +materializations for the single-writer reference. It used coordinator time, +explicit attempts, immutable result keys, and batches of at most 32 +heartbeats. + +It rejected inventing a replication wrapper because that wrapper would become +the actual coordinator authority. The heartbeat replay check assumes the +unchanged worker protocol already supplies a stable increasing message ID. +Wide fan-in/fan-out graphs, cross-process commit interruption, power loss, +batch queueing delay, and a real three-replica service remain untested. + +## Trial 2 — Explainable payment-velocity screening + +- Persona: fraud infrastructure developer with strong event-processing + experience and beginner Rust experience +- Existing system: durable checkout stream, TypeScript rule consumer, and + independently expiring Redis counters for account, card, device, and IP + identifiers +- Problem: deterministic event-time decisions, duplicate suppression, linked + late corrections, explainable alerts, replay, and customer deletion across + four consumer replicas with partition reassignment +- Outcome: no fit +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-07-30--fraud-velocity-evaluation`](../runs/2026-07-30--fraud-velocity-evaluation) + +### Evidence + +- Commands: + - `cargo test -p fraud-velocity-evaluation --all-targets --offline` + - `cargo clippy -p fraud-velocity-evaluation --all-targets --offline -- -D warnings` + - `cargo run --release -p fraud-velocity-evaluation --offline -- demo` + - `cargo run --release -p fraud-velocity-evaluation --offline -- benchmark 100000 3` +- Tests: 5 passed, 0 failed. +- Demonstration: + - The Redis-style baseline counted one retry twice, allowed arrival-time TTL + expiry to disagree with a one-minute event-time relationship, and retained + no contribution list. + - Ten deliveries became nine unique events with one inert duplicate. + - One late event produced six linked corrections. + - The latest indexed decisions matched a separate naïve scan. + - Normal close/reopen replay produced 3,311 identical decision bytes. No + process crash or torn write was tested. + - Fifteen alert outcomes were internally reconstructable from retained + records; this is not an independent oracle for every historical revision. + - Four account-owned events were scrubbed, and the audit checked both account + and card identifiers in retained rows and indexes while preserving shared + device/IP counts. +- Bounded measurements: + - Three sparse-key in-memory passes measured 951,640–1,080,871 + deliveries/s, 0.002 ms p99, 134 corrections, and 50,056 alert outcomes. + - Every round produced digest `0fb37b9c0b65c76c`. + - The payload lower bound was 39.41 MiB. + - The required 30-minute, 20-million-event, real partition-transfer, and + durable deletion-compaction gates were not run. + +### Friction trail + +The simulator evaluated the Redis counter baseline before inspecting Fold. +`Retain` was rejected because it uses transaction processing time. `TopK` was +rejected because it keeps a count rather than a duration. The simulator found +that `Ranked` and `KeyedRanked` do provide event-time range scans, but partial +adoption would still leave multi-rule aggregation, two-ID deduplication, linked +correction revisions, deletion, broker-offset atomicity, and partition transfer +as consequential custom logic. + +The dependency-free reference model first left an empty account-index +container after deletion; its own scan caught that defect. Skeptical review +then found that the scan did not explicitly audit card identifiers. The +archived version captures the deletion receipt and verifies both account and +card removal from retained rows and indexes. + +### Findings + +1. **Baseline correctness defect — high severity, high confidence.** Retries + double-count because the supplied counter model checks neither event ID. + Smallest improvement: deduplicate on stable event and merchant identities. +2. **Baseline correctness defect — high severity, high confidence.** + Arrival-time TTLs disagree with event-time windows and cannot deterministically + revise earlier decisions. Smallest improvement: retain event-time facts and + append linked corrections. +3. **Baseline missing capability — high severity, high confidence.** Counters + cannot reconstruct exact alert contributors. Smallest improvement: retain a + bounded explanation record for alerts. +4. **Poor product fit — critical if selected, high confidence.** No inspected + BogKit contract coordinates durable-stream offsets, partition ownership, or + state transfer across four consumers. +5. **Missing composed capability — high severity, high confidence.** Fold + supplies event-time range indexing, but not the complete maintained + aggregation, correction, deduplication, deletion, and partition semantics. + Smallest improvement: document the boundary; do not infer a combined + operator from one trial. +6. **API friction — medium severity, medium confidence.** Pipeline and reader + types were difficult for the Rust-beginner persona to name in ordinary + helper signatures. Smallest improvement: document reusable signature and + alias patterns. +7. **Actual BogKit defect:** none demonstrated. + +### Decision audit + +The reference selected canonical arrival and event-time ordering, two-ID +deduplication, append-only correction revisions, and contributor IDs only for +alerts. Customer deletion removes account/card-owned state while preserving +shared device/IP aggregates. + +It rejected partial Fold adoption because range indexing would not reduce the +highest-risk custom semantics. A larger design could combine partition-local +Fold ownership, an outbox/offset protocol, range indexes, custom corrections, +and deletion compaction; this trial neither built nor ruled out that +architecture. The persistent fixture ledger mutates memory before append/sync, +cannot recover a torn final TSV row, and does not durably compact deletion. + +## Skeptical review + +- Claims reproduced: both baseline failures, all 8 original trial tests, both + no-fit prerequisites, both demonstrations, the concurrent-open boundary, the + local measurements, Fold's own 18 unit and 9 documentation tests, and the + archive scans. +- Claims rejected or softened: + - the CI prototype's expired-heartbeat behavior was rejected and fixed; + - the JSON rewrite is not a correct crash-safe snapshot comparison; + - a same-process second-opener check is not a three-process replica test; + - the fraud replay is normal close/reopen, not crash recovery; + - Fold provides event-time range scans, though not the full scenario + semantics; + - alert reconstruction is internally checked, not an independent historical + oracle; + - the fraud benchmark is a sparse-key in-memory upper bound. +- Unnecessary code or dependencies removed: the unused public event-order + helper was removed. The fraud prototype remains dependency-free. CI uses + Fold and Serde; Serde JSON is used by the baseline. +- Remaining uncertainty: real replicated coordination, broker ownership, + wide dependency graphs, production crash/power behavior, load duration, + allocator/RSS overhead, durable deletion, and hardware-independent capacity. + +## Cross-run synthesis + +### New evidence + +Fold's transactions and materialized tables can support a carefully fenced +single-writer CI state machine, but they do not provide the required replicated +coordinator authority. Fold's ranked indexes can support event-time range +queries, but the fraud scenario's complete partitioned correction system +remains application architecture rather than a small component adoption. + +### Recurring evidence + +- Six independent trials now needed deeper inspection or a probe to understand + storage ownership, transactions, process boundaries, deployment shape, + operating-system limits, or stream ownership. +- Four trials encountered unrelated setup or dependency surface while needing + only Fold or no BogKit component. +- Two trials independently found composed pipeline or reader types difficult + to name in ordinary reusable code. + +### Candidate improvements + +1. **Public capability matrix** — six independent trials. Cover component + purpose, storage ownership, transaction scope, process and horizontal + deployment, filesystem and `std` requirements, strict quotas, raw-flash + non-goals, and atomic broker-offset ownership. +2. **Component-selective project path** — four independent trials. Keep the + smallest component introduction free of unrelated dependencies and setup + where practical. +3. **Nameable pipeline and reader patterns** — two independent trials. Document + type-alias, function-pointer, and ordinary helper-signature patterns. Do not + infer a new type-erasure subsystem. + +Distributed consensus, broker-offset coordination, crash-recovery examples, +and a combined event-time correction operator remain observations or +positioning boundaries, not promoted core-feature proposals. + +### No-fit and positioning signals + +BogKit remains strongest when one process may own filesystem-backed state and +the application can build its semantics inside that authority. Developers +should be able to reject it quickly when correctness depends on multiple active +writers, external transaction or stream-offset atomicity, partition transfer, +strict physical limits, raw devices, or firmware targets. + +## Validation + +- Trial-specific tests: 9 passed, 0 failed across the two corrected prototypes. +- Targeted formatting: passed for both prototypes. +- Strict lint: both prototypes and the full nested workspace passed with + warnings denied. +- Runnable demonstrations: both passed after skeptical-review corrections. +- Nested lab workspace tests: 39 passed, 0 failed. +- BogKit root workspace tests: 33 unit tests and 12 documentation tests passed, + 0 failed. The first offline attempt could not download ESE's model; the + required network-enabled rerun passed. +- Diff and ledger checks: `git diff --check` and `jq empty coverage.json` + passed. +- Archive and secret checks: changed paths are confined to + `developer-simulation/`; no generated build output, databases, binary + fixtures, credentials, private keys, or large files are included. diff --git a/developer-simulation/reports/2026-07-31.md b/developer-simulation/reports/2026-07-31.md new file mode 100644 index 0000000..1027fff --- /dev/null +++ b/developer-simulation/reports/2026-07-31.md @@ -0,0 +1,315 @@ +# Developer simulation — 2026-07-31 + + + +Two blind trials tested underexplored ordinary-software boundaries: stable +home-care rescheduling after cancellation bursts, and schema evolution in an +existing SQLite wholesale catalog. Both reached reviewed no-fit conclusions +for their stated production boundaries. The scheduling prototype still showed +useful Fold behavior inside an isolated projection; the catalog baseline met +its compact requirements without a BogKit component. + +## Trial 1 — Stable home-care gap filling + +- Persona: scheduling-platform developer with intermediate Rust experience +- Existing system: a single-node Rust service imports caregiver availability + and visits into SQLite, then uses a full-rescan greedy scheduler +- Problem: process cancellation bursts without destabilizing unaffected + assignments, violating constraints, or losing deterministic explanations +- Outcome: no fit for the SQLite-authoritative handoff as implemented +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold in an isolated projection +- Archive: + [`runs/2026-07-31--homecare-gap-fill`](../runs/2026-07-31--homecare-gap-fill) + +### Evidence + +- Commands: + - `cargo test -p homecare-gap-fill --all-targets --offline` + - `cargo fmt --manifest-path runs/2026-07-31--homecare-gap-fill/Cargo.toml -- --check` + - `cargo clippy -p homecare-gap-fill --all-targets --offline -- -D warnings` + - `cargo run -p homecare-gap-fill --release --offline` +- Tests: 5 passed, including the reviewer-required regression for spurious + travel-conflict explanations. +- Demonstration: + - The seeded representative run used 2,000 caregivers and 12,000 visits over + 14 days, exactly 10% of the requested full scale. + - After 200 cancellation changes, the incremental path preserved 100% of + unaffected assignments. The sampled full-rescan baseline preserved 98.294%. + - Both paths filled 10,000 visits and 2,761 of 2,913 urgent visits. + - Independent validation found zero assignment violations after the review + fix and rejected an explicitly unfilled visit when a caregiver was actually + eligible. + - All measured runs produced deterministic digest `3a9719e0c8654377`. + - Normal reopen recovered in 47.200–50.022 ms across recorded runs. Recovery + after a child committed, checkpointed, and then aborted took + 48.456–50.722 ms. + - Across the author, reviewer, and post-fix runs, sampled baseline p95 was + 102.118–116.540 ms and incremental p95 was 5.244–5.517 ms. +- Baseline comparison: + - The baseline met fill and urgent-coverage parity but missed the 99.5% + unaffected-assignment target. + - The local-change algorithm met the stability target at representative + scale. Fold atomically persisted the Fold-owned visits, outcomes, and + counts, but did not provide the scheduling algorithm. + - The required SQLite adapter, change feed, rebuild policy, idempotent handoff, + and cross-store transaction were absent. The isolated Fold result therefore + does not establish a safe production integration. + +### Friction trail + +The simulator began with the root README and then read the starter, +time-series, chat, and search examples. It selected Fold only after the +full-rescan baseline missed the stability target. Scheduling constraints, +candidate selection, explanations, and the validator stayed in ordinary Rust; +ESE and ANNy were rejected as unrelated. + +The first dependency resolution needed registry access. The first Fold-backed +store used anonymous closures whose composed reader type was awkward to name; +named functions made the store reusable. This is the third independent +occurrence of the existing pipeline/reader naming friction. + +The skeptical reviewer then found that the independent validator returned +`TRAVEL_CONFLICT` whenever earlier filters left a caregiver, without checking +travel feasibility. A minimal one-caregiver reproducer was incorrectly +accepted. The coordinator added an independent travel check, made an eligible +unfilled visit an error, added a regression test, and reran the full trial. + +### Findings + +1. **Prototype correctness defect, fixed — high severity, high confidence.** + The original validator accepted a spurious travel-conflict reason without + proving travel blocked all remaining caregivers. Reproduction: the + `validator_rejects_spurious_travel_conflict` test now captures the reviewer's + reproducer. Smallest improvement: independently check travel and reject an + unfilled outcome whenever any caregiver is fully eligible. +2. **Poor product fit — high severity, high confidence.** Fold's isolated + database does not share SQLite's authoritative transaction. Reproduction: + inspect `src/store.rs`; there is no SQLite adapter or handoff. Smallest + improvement: document source-of-truth and cross-store atomicity boundaries; + keep this scenario no-fit until synchronization and rebuild behavior are + proven. +3. **Performance evidence scope — medium severity, high confidence at + representative scale.** The local p95 ranges are repeatable but do not prove + the 20,000-caregiver/120,000-visit, 1 GiB requirement. Reproduction: run the + release demo. Smallest improvement: add memory-bounded, full-scale, + multi-seed measurements before production use. +4. **Time-boundary documentation — medium severity, high confidence.** The + parser tests explicit-offset instant arithmetic and rejects offset-free + strings; it does not apply IANA timezone rules or reject a nonexistent local + wall time paired with an explicit offset. Reproduction: run the time test. + Smallest improvement: put timezone-aware conversion and ambiguity policy at + the unimplemented import boundary. +5. **API friction — low severity, high confidence.** Named functions or type + aliases were again needed for a reusable composed Fold store. Reproduction: + replace the named pipeline functions with closures and compile. Smallest + improvement: document ordinary type-alias and function-pointer patterns; do + not infer a type-erasure subsystem. + +### Decision audit + +The simulator retained the full rescan as a deterministic baseline, chose an +incremental local-change scheduler to protect published assignments, and kept a +separate validator rather than sharing scheduler eligibility code. Fold was +used only to measure atomic keyed persistence and maintained counts. + +The reviewer rejected the initial “partial fit” label. SQLite must remain the +authority, and no synchronization or atomic publication boundary exists. The +final decision is no fit for the stated system, while retaining the isolated +projection as useful lower-bound evidence. Full scale, peak memory, real +SQLite integration, IANA-zone policy, mid-transaction crash injection, and +production distributions remain unresolved. + +## Trial 2 — Evolving industrial parts catalog + +- Persona: wholesale catalog backend developer with beginner-to-intermediate + Rust experience +- Existing system: an Axum and SQLite CRUD service with fixed product columns, + handwritten SQL, and category conditionals +- Problem: add structured category revisions, reliable filters, conditional + patches, and resumable import without replacing the SQLite authority +- Outcome: no fit for the authoritative catalog path +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-07-31--parts-catalog-evolution`](../runs/2026-07-31--parts-catalog-evolution) + +### Evidence + +- Commands: + - `cargo test -p parts-catalog-evolution --all-targets --offline` + - `cargo fmt --manifest-path runs/2026-07-31--parts-catalog-evolution/Cargo.toml -- --check` + - `cargo clippy -p parts-catalog-evolution --all-targets --offline -- -D warnings` + - `cargo run -p parts-catalog-evolution --release --offline -- demo` + - `cargo run -p parts-catalog-evolution --release --offline -- import-check 250000 2048` + - `cargo run -p parts-catalog-evolution --release --offline -- burst 250000` + - `cargo run -p parts-catalog-evolution --release --offline -- storage-check 250000 2048` +- Tests: 9 passed, including changed-source and conflicting-duplicate import + regressions required by skeptical review. +- Demonstration: + - The unrelated legacy table survived catalog initialization. + - A laptop revision-1 record remained readable after revision 2 activated, + then migrated explicitly to revision 2. + - Partial patching preserved omitted fields and rejected a stale version. + - Three exact/range query shapes agreed with a separately written in-memory + evaluator. + - The post-fix full import stopped after 7,000 committed rows, reopened, + validated its source fingerprint, and reached 250,000 total and distinct + IDs in 8.623 seconds. + - The post-fix burst measured 2.104 ms read p95 and 2.721 ms write p95. Across + all recorded runs the ranges were 1.518–4.037 ms and 1.389–2.721 ms. + - At 250,000 synthetic 2 KiB records, the indexed database used + 1,139,924,992 bytes versus 1,039,552,512 bytes for the baseline, or 1.097x. +- Baseline comparison: + - SQLite kept validation metadata, products, facets, conditional versions, + and import checkpoints in one transactional file. + - Fold would use a separate Fjall store and would not by itself supply runtime + validation, SQLite transaction integration, or online category revisions. + - A Fold sidecar's synchronization and storage cost were not measured; no + conclusion is made about optional future analytics. + +### Friction trail + +The simulator read the public README, examples, and relevant Fold operators +before selecting a component. The starter and search examples demonstrated +useful maintained views, but SQLite authority and evolving runtime data shapes +made those views an unnecessary second store for the compact exact/range slice. +ESE and ANNy were rejected because semantic search and recommendations were +explicit non-goals. + +The sanitized copy did not have a cached maintained SQLite binding, so the +prototype used a narrow system-`sqlite3` wrapper. This requires a linkable +system library and is not a production recommendation. + +Skeptical review found that the original importer bound a job only to its total +count and used unchecked `INSERT OR IGNORE`. The reviewer resumed one job with +changed payload size and introduced a conflicting pre-existing ID; both paths +advanced and could report a mixed import as complete. The coordinator added a +generator/payload fingerprint, complete duplicate-content comparison, rollback +before checkpoint advancement, and two regression tests. + +### Findings + +1. **Prototype correctness defect, fixed — high severity, high confidence.** + Count-only job identity and unchecked duplicate IDs could silently mix + sources or accept conflicting content. Reproduction: run the two importer + regression tests. Smallest improvement: persist a source fingerprint and + verify complete duplicate content before advancing the checkpoint. +2. **Storage integration boundary — high severity, high confidence.** Fold is + not an extension of an existing SQLite transaction. Reproduction: public + examples open a Fold-owned path. Smallest improvement: document source-of- + truth ownership and a supported synchronization/rebuild pattern if one + exists. +3. **Runtime schema mismatch — medium severity, high confidence.** This compact + prototype has three hardcoded Rust validators and one laptop revision; Fold + pipelines are also concrete startup-time Rust types. Reproduction: inspect + the validators and public pipeline construction. Smallest improvement: + document runtime-defined schemas as a fit boundary rather than implying a + new validation subsystem. +4. **Concurrency and HTTP evidence gap — medium severity, high confidence.** + One mutex serializes SQLite access, the route smoke test covers POST/GET, and + no same-version race, second process, or real-service contract was tested. + Reproduction: inspect `app` and the HTTP test. Smallest improvement: add + route-level error coverage and a two-writer conflict test before claiming + service compatibility. +5. **Performance and storage scope — medium severity, high confidence.** The + measured latency and 1.097x ratio cover in-process serialized requests and + synthetic 2 KiB rows in three categories, not a 512 MiB VM, network traffic, + mixed 2–20 KiB records, the real existing file, or a Fold sidecar. + Reproduction: run the scale commands. Smallest improvement: repeat under the + actual resource limit and data distribution. +6. **Prototype dependency risk — medium severity, high confidence.** The direct + SQLite wrapper is smaller and less mature than a maintained binding and + requires system `sqlite3`. Reproduction: inspect `src/sqlite.rs`. Smallest + improvement: use a maintained binding, migration tooling, pooled reads, and + explicit error mapping for production. + +### Decision audit + +The simulator rejected optional-column growth, unvalidated JSON, Fold as the +authority, a Fold sidecar for the compact exact/range slice, and ESE/ANNy. It +chose SQLite products, schema metadata, facet indexes, version checks, and +import checkpoints in one file. + +The no-fit conclusion is limited to this authoritative catalog path. The real +HTTP contract, 30 runtime-defined categories, 512 MiB limit, mixed record sizes, +same-version races, crash or power-loss injection, scalar-facet policy for +deeper data, and production SQLite binding remain unresolved. + +## Skeptical review + +- Claims reproduced: + - Both formatting checks, both strict lints, all original tests, and both + release demonstrations. + - Scheduling stability, fill parity, deterministic digest, and committed + Fold-state recovery across repeated runs. + - Catalog full-count import, 1.097x storage ratio, three query shapes, and + repeated burst measurements. +- Claims rejected or softened: + - Scheduling “partial fit,” independent reason validation before its fix, + IANA/DST language, full-scale performance, and cross-store atomicity. + - Catalog crash wording, general CRUD compatibility, concurrent-writer + evidence, general runtime schemas, real-data storage scope, and the + unmeasured claim that a Fold sidecar would exceed the budget. +- Quality fixes required and completed: + - Independent travel feasibility plus a regression test. + - Import source identity, duplicate-content checking, rollback, and two + regressions. + - Removal of the catalog's unused optional Fold dependency and relocation of + its test-only HTTP body dependency. + - Nested-workspace manifests and archive-only reproduction instructions. +- Remaining uncertainty: + - The full scheduling scale and memory limit; real SQLite/Fold handoff; + timezone rules; uncommitted-transaction crash points. + - The real catalog contract and data distribution; multi-process and + same-version concurrency; runtime-defined validators; crash/power loss; + production SQLite operations. + +No BogKit correctness defect was demonstrated. Both serious findings were +prototype defects found by review and fixed before archival. + +## Cross-run synthesis + +- New evidence: + - SQLite-to-Fold synchronization and source-of-truth ownership independently + blocked both trials. + - Runtime-defined schema validation and conditional update APIs remain + one-trial observations. +- Recurring evidence: + - The public capability and operational-boundary matrix is now supported by + eight independent trials. + - Nameable pipeline and reader patterns are now supported by three + independent trials. + - The component-selective project path remains supported by four trials; the + catalog's self-added optional Fold dependency was removed and is not counted. +- Candidate improvements: + 1. Public capability matrix covering component purpose, source-of-truth + storage, transaction and cross-store scope, process/horizontal deployment, + operating-system requirements, strict limits, raw-flash non-goals, and + broker ownership. + 2. Component-selective project path that avoids unrelated setup. + 3. Documentation for nameable composed pipeline and reader types. +- Observations not yet promoted: + - Runtime schema validation, conditional upsert, SQLite sidecar integration, + pooled catalog concurrency, and scheduling-specific indexes. +- No-fit or positioning signals: + - BogKit's embedded store can provide useful isolated evidence without being + safe for a system whose correctness boundary remains in SQLite. Public + onboarding should make cross-store atomicity and rebuild ownership clear. + +## Validation + +- Trial-specific tests: 5 scheduling tests and 9 catalog tests passed. +- Strict lint and formatting: both new crates passed targeted formatting and + clippy with warnings denied. The complete nested workspace also passed strict + clippy. A repository-wide format check still reports pre-existing differences + in `examples/search/src/main.rs`; that file was not modified. +- Runnable demonstrations: both demos and all three full-scale catalog commands + passed after review fixes. +- Nested lab workspace tests: all 53 tests passed. +- BogKit root workspace tests: all 45 unit and documentation tests passed after + allowing the ESE model download required by its build. +- Archive and secret checks: `git diff --check`, coverage parsing, daily-marker + uniqueness, changed-path scope, large-file, database, symlink, binary, and + credential-pattern scans passed. Every intended changed path is under + `developer-simulation/`; generated targets and databases are ignored. diff --git a/developer-simulation/reports/2026-08-01.md b/developer-simulation/reports/2026-08-01.md new file mode 100644 index 0000000..aac0b75 --- /dev/null +++ b/developer-simulation/reports/2026-08-01.md @@ -0,0 +1,336 @@ +# Developer simulation — 2026-08-01 + + + +Two blind trials tested BogKit against substantially different one-shot local +software problems: deterministic offline feature-flag evaluation and preflight +validation of CNC job bundles. Both developers reached reviewed no-fit +conclusions. Direct, dependency-light implementations met the bounded local +criteria without Fold, ESE, or ANNy. + +Skeptical review preserved both no-fit decisions but rejected broader claims +and found two high-severity CNC prototype defects before archival. The +coordinator bounded flag-snapshot reads, narrowed its memory and portability +claims, made CNC staging temporary and all-or-nothing for ordinary failures, +rechecked copied content, added regressions, and reran the evidence. No BogKit +correctness defect was demonstrated. + +## Trial 1 — Offline feature-flag parity + +- Persona: client-platform SDK developer, Rust beginner with production + TypeScript experience +- Existing system: a TypeScript kiosk SDK loads JSON snapshots, evaluates + ordered targeting rules, and returns short decision explanations +- Problem: preserve deterministic decisions and percentage assignments across + reloads and restarts while rejecting malformed snapshots without replacing + the last valid in-process configuration +- Outcome: no fit for the bounded local evaluator +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-01--offline-flag-parity`](../runs/2026-08-01--offline-flag-parity) + +### Evidence + +- Commands: + - `cargo test -p offline-flag-parity --all-targets --offline` + - `cargo fmt --manifest-path runs/2026-08-01--offline-flag-parity/Cargo.toml -- --check` + - `cargo clippy -p offline-flag-parity --all-targets --offline -- -D warnings` + - `cargo run -p offline-flag-parity --release --offline -- verify runs/2026-08-01--offline-flag-parity/fixtures` + - `cargo run -p offline-flag-parity --release --offline -- demo runs/2026-08-01--offline-flag-parity/fixtures` + - `node runs/2026-08-01--offline-flag-parity/baseline/reference-evaluator.mjs runs/2026-08-01--offline-flag-parity/fixtures` + - `cargo run -p offline-flag-parity --release --offline -- generate-benchmark /private/tmp/offline-flag-parity-benchmark.json` + - `cargo run -p offline-flag-parity --release --offline -- bench /private/tmp/offline-flag-parity-benchmark.json` +- Tests: 4 passed, including duplicate-key rejection, failed-reload retention, + a fixed hash vector, and a bounded-file-read regression added after review. +- Demonstration: + - Rust and an independently written TypeScript-style JavaScript evaluator + matched all 250 stored golden decisions and their explanations on the + measured host. + - Recursively reversing JSON object-key order left all 250 results unchanged; + targeting rule arrays remained ordered and semantically significant. + - All 12 malformed fixtures were rejected. A valid reload changed the active + configuration, and the following invalid reload left that in-process + configuration and its decision unchanged. + - Ten fresh processes produced one fingerprint, + `99c04dd07bb96094`. + - Forty measured 20,000-evaluation batches across the developer and + post-review release runs had a worst p95 of 1.333 microseconds, below the + 250-microsecond target. + - The corrected 6.2 MB, 5,000-flag/50,000-rule benchmark had sampled + same-size-reload RSS of 63,471,616–63,569,920 bytes in four fresh runs. It + passed the 64 MiB criterion with only about 3.5 MB of closest observed + headroom. This is measured prototype evidence, not a formal maximum. +- Baseline comparison: + - Parse and validate a complete candidate, then replace one immutable + in-memory snapshot only after success. + - Ordered local lookup, a specified integer hash, and structured explanations + directly satisfy the compact evaluation path. + - Fold can maintain durable incremental views, but it does not supply JSON + admission or percentage semantics and would add unnecessary storage and + lifecycle work to this bounded hot path. + +### Friction trail + +The developer began with the root README and all four public examples, then +defined the immutable TypeScript-style baseline before inspecting Fold's +transaction and snapshot surface. The examples showed durable state, +transactions, aggregates, and search, but no need in this scenario justified a +BogKit component. + +The first ordinary Cargo command attempted an index refresh in the +network-disabled copy; cached dependencies worked with `--offline`. A first +fixed-bucket assertion used an incorrect expected value and was corrected only +after an independent Node `BigInt` implementation matched Rust. The first large +benchmark retained a generic JSON tree and built fixtures in-process, reaching +146,587,648 bytes sampled RSS. Duplicate checking was made streaming and large +fixture generation moved into its own process before the passing measurements. + +Skeptical review then found that `fs::read` could allocate an entire file before +the 48 MiB policy check and that a 6.2 MB workload did not justify the broad +memory wording. File reads now stop at 8 MiB plus one byte, a sparse oversized +file is a regression case, and every memory statement is limited to the +measured workload. + +### Findings + +1. **Input-validation correctness — high severity, high confidence.** Generic + JSON-to-map parsing can accept duplicate object keys with last-value-wins + behavior. Reproduction: run the duplicate-key unit test or verify + `fixtures/malformed/06-duplicate-flag-key.json`. Smallest improvement: keep + a streaming duplicate-key preflight or use a parser that rejects duplicates. + This is a configuration-admission concern, not a BogKit defect. +2. **Prototype memory-bound defect, fixed — high severity, high confidence.** + The original whole-file read occurred before its size check and its 48 MiB + cap exceeded the measured evidence. Reproduction: the + `file_reader_stops_at_the_snapshot_limit` test uses an oversized sparse file. + Smallest improvement: cap the read itself and state only the measured memory + envelope. +3. **Poor product fit — informational severity, high confidence for this + boundary.** The direct immutable evaluator met the bounded need without + durable incremental state, embeddings, or nearest-neighbor search. + Reproduction: compare the baseline, manifest, and public examples. Smallest + improvement: document component purposes and no-fit boundaries; do not infer + a new subsystem. +4. **Performance and portability scope — medium severity, high confidence.** + The local latency and sampled RSS are repeatable, but the memory margin is + narrow and one arm64 macOS host running Rust and Node is not a platform + matrix. Reproduction: run the release benchmark and reference evaluator. + Smallest improvement: test real snapshots and a platform CI matrix before + production claims. +5. **Persistence boundary — medium severity, high confidence.** Failed reloads + preserve the active snapshot only inside the current process. Reproduction: + inspect `Evaluator::reload_file`; no durable last-known-good publication is + implemented. Smallest improvement: assign persisted delivery ownership + before considering an embedded store. + +### Decision audit + +The developer kept ordered rules, order-insensitive JSON objects, strict +candidate validation, a versionable FNV-1a 64 bucket contract, and explanations +on every decision. Rust's process-randomized map hash, rule sorting, partial +mutation, per-decision persistence, and all commercial flag formats were +rejected. + +The no-fit conclusion is limited to the bounded offline evaluator. Actual kiosk +snapshots and coercion rules, concurrent reloads, long-duration allocation, +platform parity, fuzzing, and durable last-known-good delivery remain +unresolved. + +## Trial 2 — CNC job-bundle preflight + +- Persona: manufacturing software engineer with intermediate Rust experience +- Existing system: a Python ZIP preflight checks for `manifest.json` and + allowed filename extensions before a bundle reaches an on-premises + controller +- Problem: classify incomplete or inconsistent bundles, stream size and digest + checks, produce deterministic diagnostics, and stage only a completely + rechecked valid bundle +- Outcome: no fit for the one-shot bundle gate +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-01--cnc-job-bundle-preflight`](../runs/2026-08-01--cnc-job-bundle-preflight) + +### Evidence + +- Commands: + - `cargo test -p cnc-job-bundle-preflight --all-targets --offline` + - `cargo fmt --manifest-path runs/2026-08-01--cnc-job-bundle-preflight/Cargo.toml -- --check` + - `cargo clippy -p cnc-job-bundle-preflight --all-targets --offline -- -D warnings` + - `cargo run -p cnc-job-bundle-preflight --release --offline -- generate-fixtures /private/tmp/cnc-preflight-fixtures --include-huge` + - `cargo run -p cnc-job-bundle-preflight --release --offline -- demo /private/tmp/cnc-preflight-fixtures /private/tmp/cnc-preflight-staging` + - `cargo run -p cnc-job-bundle-preflight --release --offline -- check /private/tmp/cnc-preflight-fixtures/thousand-files.zip --tools /private/tmp/cnc-preflight-fixtures/tools.json` + - `/usr/bin/time -l target/release/cnc-job-bundle-preflight check /private/tmp/cnc-preflight-fixtures/oversized-2gib-sparse.zip --tools /private/tmp/cnc-preflight-fixtures/tools.json --staging /private/tmp/cnc-preflight-staging/oversized` +- Tests: 6 passed, including skeptical-review regressions for file/parent path + collisions, incomplete-output cleanup, and content rechecking before final + naming. +- Demonstration: + - The required valid, truncated, checksum-mismatched, undeclared, missing, + case-colliding, absolute-path, parent-traversal, missing-tool, and + multi-error fixtures were classified as expected. Only the valid bundle + received a final `ready` directory. + - Two complete multi-error outputs had identical SHA-256 digests and contained + 15 stable-sorted diagnostics. + - A bundle with exactly 1,000 declared files passed with 1,001 archive members + including its manifest and 172,118 streamed bytes. + - The final archived-workspace 2 GiB sparse-member run streamed and hashed + 2,147,483,919 bytes including the manifest in 17.00 seconds. It reported 1,998,848 bytes + maximum resident set, returned only the provisional `archive_oversized` + decision, and created no staged output. + - A selected staging root that was already a symbolic link failed closed and + left its target empty. +- Baseline comparison: + - Python 3.14.6 rejected the physically truncated ZIP on open. + - The filename-only baseline still marked checksum mismatch, undeclared and + missing files, case collisions, unsafe names, a missing tool, and the + multi-error bundle ready. + - A direct streaming parser and manifest gate address this one-shot boundary; + durable views, embeddings, and nearest-neighbor search do not. + +### Friction trail + +The developer froze the runnable filename-only Python baseline before reading +BogKit implementation details. Public examples did not address archive parsing, +manifest validation, bounded streaming, or filesystem staging, so no BogKit +component was selected. + +To remain offline, the prototype used Serde plus local classic stored-ZIP, +SHA-256, and CRC32 code. Formatting and strict lint exposed ordinary quality +issues before the first passing run. Python corrected the developer's predicted +truncated-file baseline result. Sandboxed memory tools did not provide usable +process accounting; a fresh read-only timed run supplied the accepted +measurement. + +Skeptical review found two high-severity prototype defects: a late write failure +could leave incomplete output carrying the final `ready` name, and the content +copied during staging was not compared with the already validated first read. +Staging now writes to a temporary directory, cleans incomplete output, rechecks +byte count, CRC, and SHA-256, and renames only after all entries succeed. The +review also required early entry-count and member-name bounds. + +### Findings + +1. **Baseline correctness defects — critical severity, high confidence.** The + filename-only baseline accepts unsafe names plus content, declaration, size, + and tool mismatches. Reproduction: run `baseline.py` over the generated + fixtures. Smallest improvement: validate all names and a bounded manifest, + then stream declared content before readiness. These are baseline defects, + not BogKit defects. +2. **Prototype correctness defect, fixed — high severity, high confidence.** A + late staging failure could leave incomplete output named `ready`. + Reproduction: the `late_copy_failure_leaves_no_ready_or_pending_directory` + regression. Smallest improvement: stage to a temporary directory, clean it + on failure, and rename only after completion. +3. **Prototype correctness defect, fixed — high severity, high confidence.** + The original second read for staging was not rechecked against validated + content. Reproduction: the + `copied_content_is_rechecked_before_ready_is_named` regression. Smallest + improvement: verify byte count, ZIP CRC, and declared SHA-256 on the copied + stream before final naming. +4. **Compatibility limitation — medium severity, high confidence.** The local + parser intentionally rejects compression, ZIP64, data descriptors, + encryption, and multi-disk input and has no independent corpus or fuzzing. + Reproduction: inspect `src/archive.rs`. Smallest improvement: use a mature + bounded streaming library before production; keep the archived subset + explicit. +5. **Product-policy gap — medium severity, high confidence.** The provisional + 1 GiB total-member limit conflicts with the brief's occasional 2 GiB jobs. + Reproduction: the sparse fixture is rejected only as oversized. Smallest + improvement: make the local disk and size policy an explicit reviewed + configuration before deployment. +6. **Poor product fit — informational severity, high confidence.** No BogKit + component improves the one-shot trust and copy boundary demonstrated here. + Reproduction: compare the baseline and public examples. Smallest improvement: + retain a direct tool; do not infer archive APIs in BogKit. + +### Decision audit + +The developer validated before staging, streamed members with bounded buffers, +required exact UTF-8 relative names, rejected case collisions, checked manifest +references and tool inventory, and used create-new destinations. Extraction +before validation, member-sized allocation, a shell extractor, bundle repair, +and forced BogKit adoption were rejected. + +After review, a completed temporary directory is renamed to `ready` only after +copy rechecks. Existing destinations fail closed. Coordinated filesystem access, +Unicode normalization, mature ZIP interoperability, crash persistence, the +correct 2 GiB policy, and G-code semantics remain unresolved. + +## Skeptical review + +- Claims reproduced: + - Both targeted formatting checks, all 10 corrected tests, strict lints, and + release demonstrations. + - Flag golden/reference parity on one host, object-order invariance, + malformed-reload retention, restart fingerprints, repeated local latency, + and the corrected bounded-file read. + - CNC required classifications, stable diagnostics, 1,000-file boundary, + complete 2 GiB sparse stream, bounded measured RSS, and symbolic-link-root + failure. +- Claims rejected or softened: + - Cross-machine flag parity, a whole-input 64 MiB guarantee, persistent + last-known-good recovery, and any formal maximum-memory claim. + - CNC production readiness, broad ZIP compatibility, complete filesystem + coordination, and the initial staging-safety claim before its fixes. +- Quality fixes required and completed: + - Bounded flag file reads and an oversized sparse-file regression. + - Temporary CNC staging, incomplete-output cleanup, per-copy byte/CRC/SHA-256 + checks, final rename, path-type collision detection, and early metadata + bounds, all with regressions. +- Remaining uncertainty: + - Real flag schema parity, platform matrix, concurrent reloads, persisted + delivery ownership, fuzzing, and the narrow observed memory margin. + - Mature ZIP interoperability, independent parser corpus/fuzzing, coordinated + filesystem access, Unicode normalization, crash persistence, and the local + size policy. + +No BogKit correctness defect was demonstrated. The serious findings were +prototype defects fixed before archival. + +## Cross-run synthesis + +- New evidence: + - Two independent developers rejected BogKit for compact one-shot local + validation paths after comparing direct baselines with public components. + - Immutable configuration admission and manufacturing bundle staging remain + domain-specific observations, not proposed BogKit subsystems. +- Recurring evidence: + - The public capability and operational-boundary matrix is now supported by + ten independent trials. + - The component-selective project path remains supported by four trials. + - Nameable pipeline and reader patterns remain supported by three trials. +- Candidate improvements: + 1. Public capability matrix covering component purpose, source-of-truth + storage, transaction and process scope, deployment, operating-system + requirements, hard limits, and clear one-shot/no-component boundaries. + 2. Component-selective project path that avoids unrelated setup. + 3. Documentation for nameable composed pipeline and reader types. +- Observations not yet promoted: + - A configuration-snapshot example, explicit no-component workspace + isolation, ZIP/bundle validation guidance, and persisted flag delivery are + one-trial or lab-specific observations. +- No-fit or positioning signals: + - BogKit's durable incremental data tools need not be present in compact + immutable evaluators or one-shot validation gates. Public onboarding should + make component purpose clear enough that developers can make that decision + without reading implementation details. + +## Validation + +- Trial-specific tests: 4 flag tests and 6 CNC tests passed, including all + skeptical-review regressions. +- Strict lint and formatting: both targeted formatting checks and strict lints + passed; the full nested workspace passed strict Clippy with warnings denied. +- Runnable demonstrations: flag verification, the flag demo, Node reference, + restart fingerprints, repeated release benchmark, CNC normal corpus, stable + diagnostics, 1,000-file boundary, and final 2 GiB stream all passed their + expected exit and evidence checks. +- Nested lab workspace tests: all 63 tests passed. +- BogKit root workspace tests: all 45 unit and documentation tests passed after + allowing ESE's required build-model download. +- Archive and secret checks: `git diff --check`, coverage JSON parsing, + marker uniqueness, changed-path scope, symlink, generated-target, database, + archive, model, binary, credential-pattern, and file-size scans passed. The + new archives are 360 KiB and 104 KiB, and every intended changed path is + under `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-02.md b/developer-simulation/reports/2026-08-02.md new file mode 100644 index 0000000..358dd92 --- /dev/null +++ b/developer-simulation/reports/2026-08-02.md @@ -0,0 +1,324 @@ +# Developer simulation — 2026-08-02 + + + +Two blind trials tested safety boundaries around external authorities: ambiguous +carrier-label purchases whose workflow state remains in PostgreSQL, and bounded +garbage collection over externally published backup manifests. Both developers +reached reviewed no-fit conclusions. Direct prototypes met the bounded local +criteria without Fold, ESE, or ANNy, but neither constitutes a production +integration with the stated authoritative system. + +Skeptical review preserved both no-fit decisions while finding one serious +correctness defect in each prototype. The carrier journal accepted an incomplete +tail but did not remove it before later appends. The backup publisher checked its +final name before, rather than while holding, the publication lock. Both defects +were reproduced, fixed with regressions, and fully revalidated before archival. +No BogKit correctness defect was demonstrated. + +## Trial 1 — Carrier-label ambiguity + +- Persona: fulfillment-platform engineer with intermediate Rust and strong + TypeScript experience +- Existing system: a TypeScript service purchases labels through carrier APIs, + stores workflow state in PostgreSQL, retries jobs, and consumes callbacks +- Problem: a request may time out after the carrier charged and created a label; + a retry can buy a duplicate while callbacks and reconciliation race +- Outcome: no fit for the PostgreSQL-authoritative reliability core +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-02--carrier-label-ambiguity`](../runs/2026-08-02--carrier-label-ambiguity) + +### Evidence + +- Commands: + - `cargo test -p carrier-label-ambiguity --all-targets` + - `cargo fmt --manifest-path runs/2026-08-02--carrier-label-ambiguity/Cargo.toml -- --check` + - `cargo clippy -p carrier-label-ambiguity --all-targets -- -D warnings` + - `cargo run -p carrier-label-ambiguity --release -- demo --dir /private/tmp/carrier-label-demo/run` + - `cargo run -p carrier-label-ambiguity --release -- crash-demo --dir /private/tmp/carrier-label-crashes/run` + - `cargo run -p carrier-label-ambiguity --release -- acceptance --dir /private/tmp/carrier-label-acceptance/run --shipments 20000 --seeds 30` + - `runs/2026-08-02--carrier-label-ambiguity/scripts/measure-acceptance.sh target/release/carrier-label-ambiguity /private/tmp/carrier-label-measured/run /private/tmp/carrier-label-measured/output.log` +- Tests: 3 passed, including callback order/duplication, a three-seed + end-to-end fixture, and the reviewer-required partial-tail → reopen → later + commit → second-reopen regression. +- Demonstration: + - The small run completed 100 shipments with 10 ambiguous timeouts, 94 paid + labels, 5 review outcomes, 591 replayed decisions, and four simulated + restarts. + - Four real child-process exits around intent, carrier creation, confirmation, + and callback persistence recovered with one attempt and zero automatic + repurchases. + - The corrected 30-seed fixture completed 600,000 shipments, made exactly one + simulated carrier purchase call per shipment, replayed 3,478,477 decisions, + exposed 30,154 inconclusive outcomes as `needs_review`, and left no shipment + nonterminal. + - Maximum simulated convergence was 30 seconds, below the 60-second criterion. + - Developer, reviewer, and coordinator runs took 2.795–3.498 seconds and + sampled 11.48–18.20 MiB peak RSS on one arm64 macOS host, below 256 MiB. +- Baseline comparison: + - The supplied unsafe retry policy can purchase again after a timeout that + already created a paid label. + - The prototype persists one intent before the network call, never purchases + again after that intent exists, and resolves missing evidence through carrier + lookup, a matching callback, or explicit human review. + - The local journal models an atomic PostgreSQL row-plus-history transaction; + it is not a PostgreSQL, network, multi-worker, or carrier integration test. + - Fold persists local incremental state but cannot make the carrier HTTP + operation atomic with PostgreSQL and would introduce a second authority. + +### Friction trail + +The developer began with the public root README and then read the starter, +time-series, chat, and search examples. Fold's transactional writes, consistent +reads, and maintained views were clear, while ESE and ANNy addressed unrelated +search work. The required correctness boundary remained between PostgreSQL and an +external carrier, so the developer selected no BogKit dependency. + +The first implementation retained decoded history and repeatedly rescanned it. +The developer observed 293.66 MiB RSS, then moved verification to streaming replay +with compact per-shipment counters. That discarded implementation was unavailable +to the reviewer, so the historical number is not treated as independently +reproduced evidence. + +Skeptical review then appended an incomplete final journal record, reopened +successfully, committed another decision, and reproduced a checksum failure on the +next reopen. The coordinator now truncates and syncs the recognized incomplete +tail before returning a journal that may append. Review also found that the memory +script could emit a false zero when process inspection was denied; it now fails +without at least one valid sample. + +### Findings + +1. **Baseline duplicate-purchase risk — critical severity, high confidence.** + Automatic repurchase after an ambiguous timeout can create a second paid label. + Reproduction: run the deterministic acceptance fixture and inspect ambiguous + carrier-created outcomes. Smallest improvement: persist one attempt before the + request and prohibit repurchase whenever an attempt exists. +2. **Unknown must be durable review state — critical severity, high confidence.** + The corrected fixture routed 30,154 inconclusive outcomes to review without a + second purchase. Reproduction: run the 30-seed acceptance command. Smallest + improvement: make ambiguity visible rather than interpreting missing evidence as + permission to retry. +3. **Prototype partial-tail defect, fixed — high severity, high confidence.** The + original reopen ignored an incomplete tail but did not truncate it, so a later + append poisoned the journal. Reproduction: run + `journal_repairs_a_partial_tail_before_later_commits`. Smallest improvement: + truncate and sync the recognized tail before later writes. +4. **Callback evidence scope — high severity, medium confidence.** Matching + duplicate/reordered callbacks are monotonic, but conflicting transactions or + prices, callback authentication, callback-before-creation, and concurrent + reconciliation are untested. Reproduction: inspect the reducer test. Smallest + improvement: define and test the production conflict policy before integration. +5. **Poor product fit — high severity if forced, high confidence for this boundary.** + Fold does not own carrier/PostgreSQL atomicity, reconciliation, or review state. + Reproduction: compare the public Fold ownership model with the scenario's two + authorities. Smallest improvement: make authority and transaction boundaries + prominent in public onboarding; do not infer a workflow subsystem. +6. **Durability and performance scope — medium severity, high confidence.** The + evidence covers sequential simulation and ordinary process exits after completed + local sync calls on one host, not power loss, production latency, or concurrent + workers. Reproduction: run the demos and measurement. Smallest improvement: add + database-enforced attempt uniqueness plus PostgreSQL/network/concurrency and + power-failure tests before production claims. + +### Decision audit + +The developer chose intent-before-network, no automatic repurchase, authoritative +carrier evidence only when transaction and price agree, monotonic state changes, +and atomic state-plus-history commits in the journal model. Automatic retry, +absence-as-rejection, Fold as workflow authority, a Fold audit sidecar, retained +decoded history, refunds, and carrier selection were rejected. + +The no-fit conclusion is limited to this PostgreSQL-authoritative reliability core. +Carrier lookup semantics, real HTTP/database shapes, database-enforced concurrency, +callback trust/conflicts, multi-worker races, kernel or power failure, and production +deployment remain unresolved. + +## Trial 2 — Snapshot garbage-collection safety + +- Persona: backup-tools maintainer, Rust beginner with production Python experience +- Existing system: a self-hosted daemon stores content-addressed blobs on POSIX and + publishes append-only JSONL snapshot manifests; a Python collector loads all + referenced hashes before direct deletion +- Problem: bound collector memory while preventing malformed, newly published, or + crash-interrupted state from deleting live blobs +- Outcome: no fit for the external manifest/publication/quarantine protocol +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-02--snapshot-gc-safety`](../runs/2026-08-02--snapshot-gc-safety) + +### Evidence + +- Commands: + - `cargo test -p snapshot-gc-safety --all-targets --offline` + - `cargo fmt --manifest-path runs/2026-08-02--snapshot-gc-safety/Cargo.toml -- --check` + - `cargo clippy -p snapshot-gc-safety --all-targets --offline -- -D warnings` + - `cargo build -p snapshot-gc-safety --release --offline` + - `python3 runs/2026-08-02--snapshot-gc-safety/acceptance.py target/release/snapshot-gc-safety` + - `target/release/snapshot-gc-safety fixture /private/tmp/snapshot-gc-scale` + - `python3 runs/2026-08-02--snapshot-gc-safety/measure.py --scratch /private/tmp/snapshot-gc-scale/.snapshot-gc -- target/release/snapshot-gc-safety plan /private/tmp/snapshot-gc-scale realistic` + - `target/release/snapshot-gc-safety verify-plan /private/tmp/snapshot-gc-scale realistic` + - `python3 runs/2026-08-02--snapshot-gc-safety/measure.py -- target/release/snapshot-gc-safety apply /private/tmp/snapshot-gc-scale realistic` + - `python3 runs/2026-08-02--snapshot-gc-safety/measure.py -- target/release/snapshot-gc-safety resume /private/tmp/snapshot-gc-scale realistic` +- Tests: 3 Rust tests passed. The Python acceptance harness passed all 30 seeded + oracle repositories, malformed guards, publication during planning, publication + from quarantine, the reviewer-required forced same-name contention, every tested + quarantine/finalization crash boundary, and sequential idempotence. +- Demonstration: + - The post-fix requested-scale plan read 1,000,000 references across 10,000 + manifests and 300,000 blob entries, then selected all 51,000 eligible + unreachable blobs and zero referenced blobs. + - Planning took 1.485 seconds wall time, sampled 6,160,384 bytes peak RSS, and + used 38,376,656 bytes peak scratch against a 76,000,000-byte logical manifest + corpus. + - Apply quarantined all 51,000 candidates in 6.864 seconds. Resume removed all + 51,000 in 2.963 seconds, restored none, and ended `complete`. + - A forced same-name race now yields exactly one successful publisher and one + existing-name failure; the successful manifest remains intact. +- Baseline comparison: + - The frozen Python baseline directly deletes and has no quarantine, recovery, + or publisher fence. + - Its memory was highly variable: the developer observed 183,648,256 bytes, + while review sampled 124,436,480–133,971,968 bytes. The largest reviewed run + was only 245,760 bytes below 128 MiB, so the evidence supports inadequate + headroom, not a stable threshold breach. + - Fold would duplicate external manifest state and does not provide cooperative + publisher fencing, recoverable quarantine, or bounded external sorting. + +### Friction trail + +The developer read the public README and all four examples, then froze a runnable +Python baseline before inspecting Fold's public persistence model. Atomic local +transactions and snapshots were useful concepts, but no component owned the +external manifest format, publication lock, quarantine, or bounded set difference. +The prototype therefore used the standard library plus justified `serde_json`. + +The first online dependency resolution failed without registry access; cached +dependencies worked offline. Strict lint corrected two file-open and extension +checks. The developer moved unnecessary per-object directory syncs to phase commits +and reran the crash matrix. + +Skeptical review forced two cooperative publishers to wait behind the publication +lock after both passed the pre-lock final-name check. Both originally reported +success, the second replaced the first append-only manifest, and later collection +deleted the blob referenced only by the lost publication. The coordinator moved the +final-name check under the lock, cleans the losing temporary file, and added a +deterministic contention regression. + +### Findings + +1. **Baseline direct-deletion race — critical severity, high confidence.** A new + committed manifest can reference a blob after enumeration but before unlink. + Reproduction: compare the frozen baseline with the concurrent publisher test. + Smallest improvement: require temporary publication plus the shared lock and + recoverable quarantine. +2. **Prototype same-name publication defect, fixed — critical severity, high + confidence.** Two cooperative publishers could both report success and one could + replace the other's append-only manifest. Reproduction: run the forced-contention + acceptance case. Smallest improvement: test the final name while holding the lock, + reject an existing destination, and remove the losing temporary file. +3. **Malformed-input safeguard — critical severity, high confidence.** Truncated or + malformed committed manifests abort plan and apply before quarantine and identify + the file and record. Reproduction: run the acceptance harness. Smallest + improvement: preserve this fail-closed diagnostic in daemon integration. +4. **Process-exit recovery — high severity, high confidence for tested boundaries.** + Resume preserved referenced blobs and completed after every injected ordinary + exit. Reproduction: run the crash matrix. Smallest improvement: qualify the real + filesystem and power-loss behavior before durability claims. +5. **Memory evidence scope — medium severity, high confidence.** The external sorter + met the requested fixture limits, but manifest paths, chunk readers, and merge + state grow with the corpus; this is not an input-independent bound. Reproduction: + run the measured scale plan. Smallest improvement: state the measured workload and + extend the corpus shape before production sizing. +6. **Poor product fit — medium severity, high confidence.** Fold does not own the + external publication/quarantine protocol. Reproduction: compare the public Fold + model with the runnable direct solution. Smallest improvement: document the + ownership boundary; do not infer a garbage-collection subsystem. + +### Decision audit + +The developer selected fixed binary hashes, external chunk sorting, a streaming +merge, two-step quarantine/finalization, advisory publication locking, strict final +newline validation, and full manifest revalidation under the lock. SQLite, Fold, +probabilistic filters, direct unlink, and grace-period-only safety were rejected. + +Every real publisher must cooperate with the advisory lock. Same-filesystem rename, +directory-sync support, lowercase blob filenames on a case-sensitive filesystem, +publisher crash points, concurrent planners with one name, plan-file corruption, +interrupted syscalls, kernel failure, and power loss remain unresolved. + +## Skeptical review + +- Claims reproduced: + - Both targeted formatting checks, all 6 Rust tests, both strict lints, both + release builds, both small demonstrations, carrier crash recovery, the full + carrier fixture, the full backup scale, and the backup acceptance matrix. + - Both no-fit conclusions against the current public BogKit contracts. +- Claims rejected or softened: + - Carrier recovery before the incomplete-tail fix; production PostgreSQL/network, + concurrent-worker, callback-conflict, and power-loss implications; and a false + zero-memory result when process inspection was unavailable. + - Backup cooperative-publication safety before the same-name fix; a stable Python + memory-limit breach; input-independent bounded-memory, immutable-plan, and broad + filesystem durability wording. +- Quality fixes required and completed: + - Carrier incomplete-tail truncation/sync, a second-reopen regression, and a + measurement failure when no RSS sample exists. + - Backup final-name checking under the publication lock, losing-temp cleanup, and a + forced two-publisher contention regression. +- Remaining uncertainty: + - Real PostgreSQL/carrier integration, concurrent workflow workers, callback trust + and conflicts, hardware durability, and production distributions. + - Universal publisher cooperation, filesystem semantics, power failure, publisher + crash points, concurrent same-name planners, plan integrity, and corpus scaling. + +No BogKit correctness defect was demonstrated. The serious findings were defects in +today's prototypes and were fixed before archival. + +## Cross-run synthesis + +- New evidence: + - Carrier/PostgreSQL atomicity and external manifest publication/quarantine are + application authority protocols, not capabilities supplied by BogKit. + - Conservative unknown/review handling and recoverable quarantine remain + domain-specific evidence, not proposed core subsystems. +- Recurring evidence: + - The public capability and operational-boundary matrix is now supported by twelve + independent trials. + - The component-selective project path remains supported by four trials. + - Nameable pipeline and reader patterns remain supported by three trials. +- Candidate improvements: + 1. Public capability matrix covering component purpose, authoritative storage, + transaction and process scope, external authorities, cooperative filesystem + protocols, deployment, operating-system requirements, and hard limits. + 2. Component-selective project path that avoids unrelated setup. + 3. Documentation for nameable composed pipeline and reader types. +- Observations not yet promoted: + - Workflow ambiguity patterns, publisher/quarantine protocols, external sorting, + and journal-tail recovery are one-trial or lab-specific observations. +- No-fit or positioning signals: + - Embedded incremental data tools do not replace correctness protocols spanning an + external payment authority and PostgreSQL, or an externally published filesystem + corpus. Onboarding should make those ownership boundaries quickly visible. + +No new candidate was promoted. Today's two trials corroborate the existing public +capability/operational-boundary matrix but do not meet a threshold for a new BogKit +API or subsystem. + +## Validation + +- Trial-specific tests: 3 carrier and 3 snapshot tests passed, including both + skeptical-review regressions. +- Strict lint and formatting: both targeted formatting checks and strict lints + passed; the complete nested workspace passed strict Clippy with warnings denied. +- Runnable demonstrations: carrier small/crash/full-scale runs and backup + acceptance/small/full-scale runs passed after review corrections. +- Nested lab workspace tests: all 69 tests passed. +- BogKit root workspace tests: all 45 unit and documentation tests passed. +- Archive and secret checks: `git diff --check`, coverage JSON parsing, marker + uniqueness, changed-path scope, generated-output, database, archive, model, binary, + credential-pattern, symlink, and file-size scans passed. Every intended changed + path is under `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-03.md b/developer-simulation/reports/2026-08-03.md new file mode 100644 index 0000000..a4eb917 --- /dev/null +++ b/developer-simulation/reports/2026-08-03.md @@ -0,0 +1,295 @@ +# Developer simulation — 2026-08-03 + + + +Two blind trials tested BogKit against substantially different existing-software +problems: transitive software-provenance revocation and security-sensitive +offline door-policy updates. Both developers reached reviewed no-fit conclusions +after beginning with the public README and examples in separate sanitized copies +of current `main`. + +Skeptical review preserved both decisions but rejected overclaims and found a +high-severity defect in the door-policy prototype: a contiguous next version +with the wrong base was mislabeled as a missing-version gap and produced an +impossible range. The coordinator separated those failure modes, added a +no-mutation/reopen regression, moved all generated state outside the archive, +integrated both crates with the nested workspace lock, and reran all evidence. +No BogKit correctness defect was demonstrated. + +## Trial 1 — Provenance revocation impact + +- Persona: release-security platform engineer with intermediate Rust and + PostgreSQL/CI experience +- Existing system: PostgreSQL stores artifacts, dependency manifests, + attestations, releases, and cached promotion decisions; a nightly recursive + recomputation determines impact +- Problem: quickly and deterministically block every release transitively + affected by revoked, missing, or cyclic provenance without introducing a + partially published decision generation +- Outcome: no fit for the PostgreSQL-authoritative promotion gate +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold for the local input-fact persistence probe +- Archive: + [`runs/2026-08-03--provenance-revocation-impact`](../runs/2026-08-03--provenance-revocation-impact) + +### Evidence + +- Commands: + - `cargo test --manifest-path developer-simulation/Cargo.toml -p provenance-revocation-reproducer --offline --locked` + - `cargo clippy --manifest-path developer-simulation/Cargo.toml -p provenance-revocation-reproducer --all-targets --offline --locked -- -D warnings` + - `cargo build --manifest-path developer-simulation/Cargo.toml -p provenance-revocation-reproducer --release --offline --locked` + - `provenance-revocation-reproducer generate | provenance-revocation-reproducer run candidate` + - `provenance-revocation-reproducer generate | provenance-revocation-reproducer run reference` + - an abort-on-panic release build with `PROVENANCE_CRASH=before_commit:1` + and `PROVENANCE_CRASH=after_commit:1` +- Tests: 5 passed for transitive revocation, transitive missing manifests, + cycles, complete acyclic input, order independence, and duplicate-edge set + semantics. +- Demonstration: + - The intentionally incomplete one-hop negative control matched only 1 of 3 + decisions. It incorrectly approved a transitively revoked release and a + release containing a reachable cycle; both engines blocked the missing root. + - The reference produced stable tested paths + `app -> middle -> revoked-base` and `cycle-a -> cycle-b -> cycle-a`. + - Each tiny final Fold state occupied 48 KiB; this fixed overhead is not + evidence for the requested storage-ratio target. + - Abort before commit exited 134 and reopened without the artifact. Abort + after commit exited 134 and reopened with the artifact. +- Baseline comparison: + - The real PostgreSQL baseline was evaluated conceptually, not reproduced. + Its recursive query can compute exact reachability from a transactionally + consistent authoritative snapshot, but nightly publication is too slow. + - The runnable slow reference loads Fold facts into ordinary Rust collections + and is only a bounded correctness oracle, not a PostgreSQL or production + performance baseline. + - The current public Fold operators do not supply joins, recursion, a feedback + edge, fixed-point iteration, or graph reachability. A custom `Push` node + could be written, but it would implement the graph engine and still leave a + PostgreSQL-to-local-store reconciliation protocol. + +### Friction trail + +The developer read the root README and all four public examples. Fold's atomic +transactions, retractions, and snapshots looked useful, but the examples showed +one delta moving through static branches rather than correlation of changing +relations or recursive reachability. Public API inspection confirmed the absence +of a supported join or fixed-point abstraction. A custom low-level operator was +technically possible but would consume the prototype boundary while leaving the +authoritative database handoff unsolved. + +The first standalone Cargo attempt tried to refresh the registry; cached offline +resolution succeeded. Skeptical review later integrated the archive with the +existing nested lock, reproduced all results on Fjall/lsm-tree 3.1.6, renamed +the one-hop engine as a negative control, and required process-abort evidence so +the report would not overstate ordinary Rust-unwind recovery. + +### Findings + +1. **No public join or recursive reachability — missing capability, blocker + severity, high confidence.** Reproduction: inspect the public operator list + and examples. Smallest improvement: document the boundary in the public + capability matrix. A recursive engine remains a one-trial observation, not a + threshold-qualified subsystem candidate. +2. **PostgreSQL/local authority split — poor product fit, blocker severity, high + confidence.** Reproduction: compare the stated PostgreSQL authority with + Fold's local embedded store and process-owned stream. Smallest improvement: + keep computation/publication in the authoritative database unless a complete + source-offset and generation-publication protocol is assigned. +3. **One-hop negative control approves unsafe releases — prototype correctness + defect, blocker severity, high confidence.** Reproduction: run both engines + over the three-query corpus. Smallest improvement: never use the negative + control as a gate; it is not an implemented Fold pipeline or a BogKit defect. +4. **Scale and witness scope — performance/evidence limitation, major severity, + high confidence.** The requested 500,000-artifact/5-million-edge fixture, + 100-updates-per-second concurrency, memory bound, and storage ratio were not + measured after correctness no-fit was established. Sorted traversal gives a + stable first witness on tested fixtures, not a globally minimal proof. + +### Decision audit + +The developer rejected nightly-only recomputation, a Fold side cache, a one-hop +gate, ESE, ANNy, and a bespoke recursive `Push` node. The reviewed direction is +an incremental reverse-dependency generation published atomically in PostgreSQL, +or another proven recursive graph system that can preserve the named authority. +The no-fit decision does not claim that arbitrary custom Rust inside Fold could +never meet the numeric targets. + +## Trial 2 — Offline door policy update + +- Persona: building-access systems developer, Rust beginner with production + embedded-C experience +- Existing system: PostgreSQL compiles a nightly sorted policy file for 2,000 + intermittently connected controllers +- Problem: safely apply frequent revocations and temporary grants under + duplicate, reordered, skipped, truncated, and power-interrupted delivery while + keeping a complete prior policy active until the new generation is verified +- Outcome: no fit for the fixed-image controller safety boundary +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold for transactional keyed-state fit evidence +- Archive: + [`runs/2026-08-03--offline-door-policy-update`](../runs/2026-08-03--offline-door-policy-update) + +### Evidence + +- Commands: + - `cargo test --manifest-path developer-simulation/Cargo.toml -p offline-door-policy-fit-probe --offline --locked` + - `cargo clippy --manifest-path developer-simulation/Cargo.toml -p offline-door-policy-fit-probe --all-targets --offline --locked -- -D warnings` + - `cargo build --manifest-path developer-simulation/Cargo.toml -p offline-door-policy-fit-probe --release --offline --locked` + - three release demonstrations using separate empty `/private/tmp` state roots +- Tests: 2 passed, including the reviewer-required wrong-base/no-mutation/reopen + regression. +- Demonstration: + - Zero mismatches across 20,000 generated authorization queries before and + 20,000 after 50,000 revocations. + - The final nested-workspace runs applied 60,000 initial grants in 49-63 ms, + applied 50,000 revocations in 48-51 ms, and checkpointed in 4-5 ms. + - A wrong-base version 2 returned `BaseMismatch`, left coherent version-1 + status, did not revoke the tested grant, and preserved it across reopen. + - The same active version was labeled `SameVersionIgnoredUnverified`; payload + identity and authenticity were not claimed. + - Old and skipped versions were rejected, contiguous repair succeeded, and + final active version 4 survived clean checkpoint/reopen. + - The open Fold directory had 14 files with a 67,115,332-byte sparse logical + extent and 3,129,344 allocated bytes. Clean close left 11 files with + 3,072,778 logical and 3,104,768 allocated bytes. + - Whole-process host RSS was 25,526,272-26,820,608 bytes. It includes the + reference map, generated bundles, runtime, code, and database mappings, so + the 4 MiB controller-memory bound remains not demonstrated. +- Baseline comparison: + - The naive in-place file baseline mixes old and new bytes after the first + modeled 4 KiB write. + - Fold correctly handled transactional grant retractions and version metadata + on the host, but uses a filesystem directory rather than the fixed 16 MiB + flash image and does not expose each physical 4 KiB write for exhaustive + power-cut injection. + - Signed framing, length/truncation checks, bundle identity, and key management + are application-specific work outside the probe. + +### Friction trail + +The developer selected Fold after the starter and time-series examples showed +atomic keyed retractions and reopen. A pre-created regular 16 MiB file could not +serve as the database path; the public constructor documents that store-open +failure may panic. Host results were fast, but neither sparse filesystem layout +nor whole-process RSS established the fixed flash or memory contract. + +Skeptical review reproduced the full evidence on the final nested dependency +set, found the wrong-base classification defect, showed that rejected-gap status +is process-local, and showed that same-version content was ignored without +identity verification. The coordinator fixed the consequential wrong-base bug, +narrowed both softer claims, moved runtime/test state to caller-supplied or +unique temporary paths, and reran the suite and demonstration three times. + +### Findings + +1. **Wrong-base classification, fixed — prototype correctness defect, high + severity, high confidence.** Original reproduction: after active version 1, + version 2 based on version 0 returned `Missing { expected: 2, received: 2 }` + and `missing=2..1`. Smallest improvement completed: return `BaseMismatch`, + make no policy mutation, keep coherent status, and cover reopen. +2. **Fixed/raw-flash mismatch — poor product fit, critical severity, high + confidence.** Reproduction: the regular-file-path probe and absence of a raw + block interface. Smallest improvement: retain fixed/raw flash as a documented + non-goal and use a purpose-built controller format; do not infer a new engine. +3. **No controllable physical write-cut boundary — missing capability, critical + severity, high confidence.** Reproduction: Fold exposes logical transactions + and checkpoint, not every 4 KiB physical write. Smallest improvement: build + the controller-specific dual-generation recovery harness outside BogKit and + keep the boundary visible in onboarding. +4. **Working-memory target not demonstrated — performance limitation, high + severity, medium confidence.** Reproduction: three whole-process RSS runs. + Smallest improvement: measure a real controller implementation with allocator + and storage telemetry; do not attribute the harness RSS to Fold. +5. **Bundle trust and status scope — missing application capability, high + severity, high confidence.** Signed/truncated intake, same-version payload + identity, and persistent rejected-gap diagnostics were not implemented. + Smallest improvement: define them in the application wire/recovery protocol; + this does not qualify as a BogKit subsystem candidate. +6. **Durability boundary can be overread — documentation gap, medium severity, + high confidence.** Smallest improvement: distinguish process commit, + checkpoint, filesystem behavior, fixed capacity, and raw-flash power cuts in + the public capability matrix. + +### Decision audit + +The developer rejected in-place file replacement, Fold on the controller, ESE, +ANNy, and Fold only in the central compiler. A purpose-built fixed-image format +with an inactive staging region, redundant activation record, streaming signed +verification, monotonic version rules, and torn-write recovery owns the actual +safety boundary. PostgreSQL remains the authoritative compiler input. + +## Skeptical review + +- Claims reproduced: + - Both final formatting checks, all 7 new tests, strict lints, release builds, + both provenance decisions, both abort boundaries, and three door runs. + - The absence of public join/recursive reachability, the local transaction + boundary, filesystem-directory storage, and both no-fit conclusions. +- Claims rejected or softened: + - The one-hop negative control as a real Fold composition or BogKit defect; + the slow reference as a reproduced PostgreSQL baseline; globally minimal + witnesses; broad crash/power-loss implications; and scale claims. + - Door duplicate identity, persisted rejected-gap status, a Fold-specific + memory failure, general corruption behavior, hardware timing, and a signed + envelope as a core candidate. +- Quality fixes required and completed: + - Distinct wrong-base handling with non-mutation/reopen regression. + - Caller-supplied/temporary state, nested-lock integration, removal of package + locks/generated databases/build output, archive READMEs, and all final reruns. +- Remaining uncertainty: + - Production PostgreSQL ingestion/publication, correct incremental graph scale, + global witness policy, concurrency, memory, and storage amplification. + - Real signed bundles, controller hardware, fixed-image layout, persisted + diagnostics, wear, erase geometry, physical write atomicity, and power loss. + +No BogKit correctness defect was demonstrated. The serious defect was in the +door-policy prototype and was fixed before archival. + +## Cross-run synthesis + +- New evidence: + - Exact recursive graph processing and fixed raw-flash policy activation sit + outside the current public BogKit abstractions for these two workloads. + - Provenance joins/recursion and signed door bundles remain one-trial + observations; neither qualifies as a new core API or subsystem candidate. +- Recurring evidence: + - The public capability and operational-boundary matrix is now supported by + fourteen independent trials. + - The door trial independently repeats the July 29 raw-NOR/fixed-flash no-fit + boundary. + - The component-selective project path remains supported by four trials. + - Nameable pipeline and reader patterns remain supported by three trials. +- Candidate improvements: + 1. Public capability matrix covering component purpose, authoritative storage, + transaction/process scope, joins/recursion, external authorities, + filesystem/raw-flash boundaries, deployment, OS requirements, and hard + limits. + 2. Component-selective project path that avoids unrelated setup. + 3. Documentation for nameable composed pipeline and reader types. +- Observations not yet promoted: + - Durable recursive provenance, canonical graph witnesses, signed delta + delivery, bundle identity, and controller activation formats. +- No-fit or positioning signals: + - Embedded incremental state is not a substitute for an authority-safe + PostgreSQL graph publication protocol or a fixed-capacity raw-flash recovery + format with explicit physical fault injection. + +No new candidate was promoted. + +## Validation + +- Trial-specific tests: all 7 passed, including the skeptical-review regression. +- Strict lint and formatting: every nested package passed its formatting check; + both new packages and the complete nested workspace passed strict Clippy with + warnings denied. +- Runnable demonstrations: the provenance negative control/reference, both + process-abort boundaries, and all three corrected door runs passed with the + results above. +- Nested lab workspace tests: all 76 tests passed. +- BogKit root workspace tests: all 45 unit and documentation tests passed after + the sandboxed first attempt reached ESE's required model download and the + unchanged suite was rerun with network access. +- Archive and secret checks: `git diff --check`, coverage JSON parsing, marker + uniqueness, changed-path scope, generated-output, database, archive, model, + binary, credential-pattern, symlink, and file-size scans passed. Every intended + changed path is under `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-04.md b/developer-simulation/reports/2026-08-04.md new file mode 100644 index 0000000..ac4ac4f --- /dev/null +++ b/developer-simulation/reports/2026-08-04.md @@ -0,0 +1,285 @@ +# Developer simulation — 2026-08-04 + + + +Two blind trials tested BogKit against substantially different existing-software +problems: mixed-version event-contract safety during rolling deployments and +security-sensitive OCR redaction remapping after text correction. Both fresh +developers began with the public README and examples in separate sanitized +copies of current `main`, built runnable standalone prototypes, and reached +reviewed no-fit conclusions. + +Skeptical review preserved both fit decisions but rejected initial quality +claims and found four archive blockers. Duplicate raw JSON member names could +false-allow a contract. The redaction mapper could cover the wrong repeated +occurrence, trust stale completed files, and reconnect a stale checkpoint to +truncated partial output. The coordinator fixed all four classes with exact +regressions and reran the affected demos, strict checks, and full workloads. +None was a BogKit correctness defect. + +## Trial 1 — Mixed-version contract gate + +- Persona: deployment-platform developer with intermediate Rust and production + Go experience +- Existing system: repository-owned versioned JSON contracts and topology, with + a CI script that compares only adjacent versions +- Problem: cover every supported producer-consumer version pair during rolling + deployment and return deterministic minimal counterexamples +- Outcome: no fit for the bounded read-only compatibility gate +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-04--mixed-version-contract-gate`](../runs/2026-08-04--mixed-version-contract-gate) + +### Evidence + +- Commands: + - `cargo test --offline --locked --manifest-path developer-simulation/Cargo.toml -p mixed-version-contract-gate` + - `cargo clippy --offline --locked --manifest-path developer-simulation/Cargo.toml -p mixed-version-contract-gate --all-targets -- -D warnings` + - `cargo build --release --offline --locked --manifest-path developer-simulation/Cargo.toml -p mixed-version-contract-gate --bins` + - the archived generator and gate against external demo and workload + directories under `/private/tmp` + - `python3 .../oracle.py .../fixtures/semantic_cases.json` +- Tests: 8 passed after the reviewer-required duplicate-member regression. +- Demonstration: + - The independent Python oracle matched all 64 fixed semantic cases. + - The demo evaluated all 9 mixed-version pairs and blocked exactly the 3 + producer versions incompatible with the narrowed consumer contract. + - Raw duplicate members at the root, contract, schema, topology, fleet, and + candidate layers now return `review-required`; the reviewer's exact + `{"type":"string","type":"integer"}` false allow is fixed. + - Skeptical review reversed and identically duplicated the full input and + obtained byte-identical output. A reverse relationship produced 18 bounded + pairs and the same 3 issues. + - The corrected full fixture contained 300 services, 120 topics, 1,800 + contracts, 12,000 relationships, 25 candidates, and 108,000 evaluated + version pairs. The final nested-workspace run returned 237 issues and no + review issues in 0.52 seconds, with 119,144,448 bytes maximum RSS. Output + SHA-256 remained + `d5cbec30584c237b56d03a507292ffb4772234c34be1f47a5ebbb3436e4fd5d7`. +- Baseline comparison: + - The stated existing gate was modeled but not preserved as a runnable + baseline, so no measured speed or correctness improvement over it is + claimed. + - The application-specific contract subset and default policy are not full + JSON Schema, Avro, or Protobuf semantics. + - Fold persistence would not remove strict parsing, recursive inclusion, + witness construction, or diagnostics from this bounded immutable pass. + +### Friction trail + +The developer read the public README and starter, timeseries, chat, and search +examples before inspecting manifests or component source. A clean-target +`cargo run -p starter --offline` reached ESE's model download even though the +starter source uses only Fold. The developer then confirmed that both the +starter manifest and project scaffold include all three components. + +After reading Fold's public stream and pipeline interfaces, the developer kept +the gate standalone with `serde` and `serde_json`. Skeptical review reproduced +the claimed workload but defeated `serde_json::Value`'s last-member-wins parsing +with a raw duplicate type key. The coordinator added a duplicate-rejecting +recursive JSON visitor and reran the complete evidence set. + +### Findings + +1. **Duplicate-member false allow, fixed — prototype correctness defect, high + severity, high confidence.** Reproduction: the reviewer's conflicting raw + `type` keys. Smallest improvement completed: reject duplicate object members + before conversion to `Value`, at every nesting level. +2. **Unconditional starter/scaffold components — API friction and documentation + gap, moderate severity, high confidence.** Reproduction: the clean-target + starter run plus current manifests. Smallest improvement: remove unused + starter dependencies and make generated components opt-in; disclose ESE's + model artifact when selected. +3. **Bounded batch analysis is poor product fit — informational severity, high + confidence.** Reproduction: compare the four immutable inputs and pure output + with Fold's durable runtime delta/materialization model. Smallest improvement: + document when plain in-memory analysis is preferable; no new core API is + justified. +4. **Evidence scope — moderate severity, high confidence.** The fixed cases are + broad but not a formal inclusion proof; timing and memory are one generated + workload on one host. + +### Decision audit + +The developer chose a standalone strict parser, a documented small schema +language, receiver-materialized defaults, one smallest witness per version pair, +ordered semantic identities, fail-review behavior for unsupported input, and an +in-memory touched-pair cache. Fold, ESE, ANNy, durable cross-job caching, full +standards support, deployment orchestration, and application-semantic proofs +were rejected. Formal exhaustiveness, arbitrary deeply nested witness +minimality, and production contract semantics remain unresolved. + +## Trial 2 — OCR redaction remapping + +- Persona: public-records processing engineer, Rust beginner with production + Python experience +- Existing system: human-reviewed UTF-8 offsets are clamped into OCR text after + Unicode cleanup and glyph regeneration +- Problem: remap reviewed spans without exposing corrected sensitive text, + silently guessing ambiguity, or publishing incomplete geometry +- Outcome: no fit for the independent page-local transformation +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-04--ocr-redaction-remap`](../runs/2026-08-04--ocr-redaction-remap) + +### Evidence + +- Commands: + - `cargo test --offline --locked --manifest-path developer-simulation/Cargo.toml -p ocr-redaction-remap` + - `cargo clippy --offline --locked --manifest-path developer-simulation/Cargo.toml -p ocr-redaction-remap --all-targets -- -D warnings` + - `developer-simulation/runs/2026-08-04--ocr-redaction-remap/run_demo.sh` + - a release build plus the exact external 5,000-page workload under + `/private/tmp` +- Tests: 7 passed, including the reviewer's repeated-occurrence, changed-input, + truncated-output, changed-audit, stale-checkpoint, and short-partial + regressions. +- Demonstration: + - The final fixture run checked 244 pages: 243 exact, 1 conservative, 0 + blocked, 0 expected-rectangle mismatches, and no complete sentinel strings + in output or audit. + - Reversed and duplicated spans remained byte-identical. Controlled stops at + pages 73 and 191 resumed to the uninterrupted bytes. + - Invalid offsets, UTF-8 boundaries, missing geometry, and contradictory + geometry blocked with zero rectangles; invalid raw UTF-8 produced only a + static error code and line number. + - The reviewer's changed-first repeated secret is now conservatively covered + at the source-mapped region instead of being mislabeled exact at the + unchanged second occurrence. + - Completed resume now verifies exact paths, lengths, and SHA-256 values for + input, output, and audit. Changed input, truncated output, and changed audit + fail. Fresh runs invalidate stale state; short or changed partial prefixes + fail instead of being extended. + - The corrected nested-workspace 5,000-page, 20-million-ASCII-scalar, + 150,000-span workload completed in 38.82 seconds with 5,947,392 bytes + maximum RSS. It emitted + 5,000 output lines, 5,000 audit lines, and 600,000 exact rectangles. A + second full output and audit were byte-identical. +- Baseline comparison: + - `baseline_clamp.py` deterministically shows partial exposure for the stated + stale-offset algorithm without printing the modeled secret. This is + conditional evidence about that algorithm, not proof of the production + implementation. + - Fold owns durable incremental views, while ESE and ANNy provide semantic + embeddings and approximate retrieval. None supplies authoritative Unicode + correspondence or glyph-redaction geometry. + +### Friction trail + +The developer followed the same public onboarding order independently and hit +the same unused ESE model-download coupling before inspecting manifests. After +freezing the clamping reproducer, the developer selected no BogKit component and +built a page-streaming Rust transformer with bounded edit alignment, complete +geometry validation, conservative fallbacks, content-free records, and +controlled process checkpoints. + +Skeptical review reproduced the benign fixtures but constructed a changed first +occurrence followed by an unchanged duplicate. The mapper redacted only the +second occurrence. Review also changed completed input, truncated final output, +and reconnected a stale checkpoint to short partial files. The coordinator fixed +all three classes and reran every affected check and scale case. + +### Findings + +1. **Stale-offset clamp exposure — modeled baseline correctness defect, + critical severity, high confidence for the reproducer.** Smallest improvement: + stop clamping revised offsets; validate correspondence and block unresolved + mapping or geometry. +2. **Wrong repeated occurrence, fixed — prototype confidentiality defect, + critical severity, high confidence.** Smallest improvement completed: compare + literal candidates with source-position edit mapping and conservatively cover + disagreement. +3. **Completed and partial recovery integrity, fixed — prototype correctness + defects, critical severity, high confidence.** Smallest improvement completed: + strong final bindings, fresh-state invalidation, and prefix validation that + never extends a short file. +4. **Unconditional starter/scaffold components — API friction and documentation + gap, moderate severity, high confidence.** This independently repeats Trial + 1's setup evidence. +5. **Page-local remapping is poor product fit — informational severity, high + confidence.** No current public component removes alignment, ambiguity, or + geometry work; approximate matching would be inappropriate. +6. **Evidence scope — moderate severity, high confidence.** Sentinel checks are + not a general leakage proof, the workload is ASCII, metadata must be declared + non-sensitive by the caller, and recovery covers controlled process restart, + not filesystem or power-loss durability. + +### Decision audit + +The developer chose validated UTF-8 byte spans, Unicode grapheme rectangles, +bounded normalization and edit mapping, conservative token/line coverage, +complete one-to-one geometry, sorted duplicate handling, allow-listed identifier +metadata, paired external outputs, prefix-validated checkpoints, and SHA-256- +bound completion markers. Fold, ESE, ANNy, approximate cross-page matching, PDF +processing, OCR, entity detection, bidirectional layout, and power-loss claims +were rejected. Production normalization rules, edits beyond 256, arbitrary +Unicode scale, metadata classification, and real filesystem failure remain +unresolved. + +## Skeptical review + +- Claims reproduced: + - Both final no-fit decisions, starter/scaffold coupling, contract demo and + full workload, redaction baseline reproducer, fixture demo, controlled + resumes, malformed-input failures, and final scale run. +- Claims rejected or softened: + - Strict contract parsing before duplicate-key rejection; a measured + existing-gate comparison; broad standards, Unicode, confidentiality, + portability, memory, durability, crash, power-loss, or sentinel-leakage + guarantees; and the original completed-resume claims. +- Quality fixes required and completed: + - Recursive duplicate-member rejection. + - Source-correspondence validation for literal redaction matches. + - SHA-256-bound completed state and stale/short partial rejection. + - Nested-workspace integration, external generated state, archive READMEs, + and final reruns. +- Remaining uncertainty: + - Formal contract inclusion and witness proofs, production schema semantics, + multi-host performance, full Unicode production corpora, authoritative + metadata classification, arbitrary layout, and storage/power failures. + +No BogKit correctness defect was demonstrated. The serious findings belonged to +the modeled clamping baseline and the two prototypes and were fixed or narrowly +classified before archival. + +## Cross-run synthesis + +- New evidence: + - Contract-language inclusion and reviewed Unicode/glyph correspondence are + application-specific subsystems outside the current public BogKit roles. + - Both trials independently encountered unconditional all-component starter + and scaffold dependencies. +- Recurring evidence: + - The public capability and operational-boundary matrix is now supported by + 16 independent trials. + - The component-selective project path is now supported by 6 trials. + - Nameable pipeline and reader patterns remain supported by 3 trials. +- Candidate improvements: + 1. Public capability and operational-boundary matrix. + 2. Component-selective project path, including a true Fold-only starter. + 3. Documentation for nameable composed pipeline and reader types. +- Observations not yet promoted: + - Contract-language tooling, Unicode/glyph redaction, final-output binding, + and conservative correspondence APIs. +- No-fit or positioning signals: + - BogKit's persistent local data semantics, embeddings, and approximate + retrieval should not replace bounded standards analysis or exact + security-sensitive document transformation when those components do not own + the authority or algorithm. + +No new candidate was promoted. + +## Validation + +- Trial-specific tests: all 15 corrected tests passed. +- Strict lint and formatting: both new packages and the complete nested + workspace passed formatting and Clippy with warnings denied. +- Runnable demonstrations: contract demo/oracle/full workload and corrected + redaction fixture/resume/full workload passed with the scoped results above. +- Nested lab workspace tests: all 91 tests passed. +- BogKit root workspace tests: all 45 unit and documentation tests passed. +- Archive and secret checks: `git diff --check`, coverage JSON parsing, marker + uniqueness, changed-path scope, generated-output, database, archive, model, + binary, credential-pattern, symlink, and file-size scans passed. Every intended + changed path is under `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-05.md b/developer-simulation/reports/2026-08-05.md new file mode 100644 index 0000000..53c304a --- /dev/null +++ b/developer-simulation/reports/2026-08-05.md @@ -0,0 +1,298 @@ +# Developer simulation — 2026-08-05 + + + +Two blind trials tested BogKit against unrelated existing-software problems: +streaming paired-FASTQ demultiplexing with bounded output handles, and exact +map-tile invalidation from authoritative parcel geometry deltas. Fresh +developers began from the public README and examples in separate sanitized +copies of current `main`. Both built runnable standalone Rust prototypes and +reached reviewed no-fit conclusions. + +Skeptical review preserved both fit decisions but found correctness and evidence +blockers in each prototype. The coordinator fixed case-insensitive output-name +collisions, incomplete FASTQ identifier validation, a false-passing file-handle +observer, duplicate JSON member acceptance, and invalid polygon topology +acceptance. All affected tests, demonstrations, observers, and scale workloads +were rerun. None of these findings was a BogKit correctness defect. + +## Trial 1 — Bounded FASTQ barcode spill + +- Persona: sequencing-pipeline engineer with intermediate Rust and production + Python experience +- Existing system: a Python demultiplexer that routes paired reads by exact + barcode and keeps one output handle open per sample +- Problem: stream 1,000,000 paired records to 384 sample destinations, apply + unique Hamming-distance-one correction, preserve ambiguous ties, and keep no + more than 24 sample output files open +- Outcome: no fit for this stateless, one-pass byte router +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-05--fastq-barcode-spill`](../runs/2026-08-05--fastq-barcode-spill) + +### Evidence + +- Commands: + - `cargo test --offline --locked --manifest-path developer-simulation/Cargo.toml -p fastq-barcode-spill` + - `cargo clippy --offline --locked --manifest-path developer-simulation/Cargo.toml -p fastq-barcode-spill --all-targets -- -D warnings` + - `developer-simulation/runs/2026-08-05--fastq-barcode-spill/scripts/verify.sh` + - the archived `measure_open_files.py` observer against the release binary + - two release-mode 1,000,000-pair runs from a non-seekable generated pipe +- Tests: 5 Rust tests and the corrected Python integration suite passed. +- Demonstration: + - Clean exact-match outputs were byte-identical to the trial-created Python + baseline for every sample and unmatched file. + - Seeded unique one-base correction, unmatched routing, barcode-level ties + between two whitelist entries for the same sample, and ties across samples + all produced the expected classifications. + - Truncation, unequal sequence and quality lengths, mismatched pair IDs, + invalid identifiers, and colliding output aliases failed without a + completion manifest or sensitive read/sample content in diagnostics. + - An independent `lsof` observer measured 24 open FASTQ outputs across 45 + successful polls; a forced observer failure now fails the check. + - The final mixed run processed exactly 1,000,000 pairs: 250,000 exact, + 250,000 corrected, 250,000 ambiguous, and 250,000 unmatched. It completed + in 1.93 seconds, used 63,750,144 bytes maximum RSS, and reported 24 maximum + open writers. + - Two complete 387-file output trees produced the same SHA-256: + `88e0f8be6e3f5622b19d4cf530c1af20a2f69ce8ece18c3bf347a466e22cd644`. +- Baseline comparison: + - The repository contained no production FASTQ implementation, so the Python + baseline is a minimal trial-created model of the stated exact-match and + all-handles-open behavior, not evidence about an unavailable production + program. + - Its 384-output failure under a 64-file process limit confirms the modeled + design pressure only. No production speedup claim is made. + - Fold maintains durable views; ESE and ANNy provide embeddings and + approximate retrieval. None removes the required parsing, exact barcode + classification, byte preservation, or file-descriptor management. + +### Friction trail + +The developer read the README, starter, timeseries, chat, and search examples, +then inspected the project scaffold. Because the scaffold creates a public +example with all three components, the developer made a standalone trial and +modeled the unavailable Python baseline first. The first clean fixture contained +an extra blank line, the first parser used record-relative error lines, and a +naive correction path scanned every barcode. Those trial defects were fixed +before final evidence. + +Round-robin samples caused excessive output reopening even with a bounded LRU, +so the developer added per-destination buffers while preserving read order. A +first piped benchmark raced barcode-map creation, and sandboxed macOS resource +reporting could not read the RSS counter; both measurements were corrected and +rerun. Skeptical review then reproduced the headline workload but showed that +`Alpha`/`alpha` and reserved aliases could share a filename, malformed read IDs +were accepted, and the external observer could pass without a valid sample. The +coordinator added preflight name checks, explicit supported-ID validation, and a +positive-observation requirement, then reran the suite, observer, and two full +workloads. + +### Findings + +1. **Case-folded output collision, fixed — correctness defect, high severity, + high confidence.** Reproduction: use sample aliases differing only by ASCII + case or a reserved `Ambiguous`/`Unmatched` alias. Smallest improvement + completed: reject all case-folded filename collisions before output creation. +2. **Incomplete identifier admission, fixed — correctness defect, moderate + severity, high confidence.** Reproduction: empty/control identifiers or + contradictory slash and CASAVA mate roles. Smallest improvement completed: + validate the documented identifier subset without echoing rejected data. +3. **False-passing descriptor observer, fixed — evidence defect, high severity, + high confidence.** Reproduction: force the observer command to fail for every + poll. Smallest improvement completed: require successful positive polls and + retain the real 45-poll result. +4. **All-component scaffold — API friction and documentation gap, moderate + severity, high confidence.** Reproduction: inspect the current project + generator. Smallest improvement: make components opt-in. +5. **Stateless byte routing is poor product fit — informational severity, high + confidence.** Smallest improvement: state this boundary in the public + capability matrix; no new component is justified. +6. **Evidence scope — moderate severity, high confidence.** Resource figures are + one generated workload on one host, the parser intentionally supports a + bounded identifier grammar, individual FASTQ lines are not length-capped, + and manifest creation does not claim filesystem power-loss durability. + +### Decision audit + +The developer chose a streaming paired-record reader, byte-preserving output, +fixed-length exact and precomputed neighbor maps, barcode-level ambiguity, +per-destination buffering, a 24-entry writer LRU, static privacy-safe errors, +and an atomically renamed completion manifest. Fold, ESE, ANNy, one writer per +sample, approximation, compression, parallel parsing, full FASTQ dialect +support, and power-loss guarantees were rejected. Maximum record length, +production barcode grammars, compressed input, and multi-host performance +remain unresolved. + +## Trial 2 — Parcel delta tile planner + +- Persona: civic GIS platform developer, Rust beginner with production + TypeScript experience +- Existing system: a stateless TypeScript job that scans map tiles touched by + authoritative old and new parcel geometries +- Problem: produce the exact deterministic `z/x/y` invalidation plan for + Polygon and MultiPolygon deltas at zooms 12–16 without reading or writing a + parcel store +- Outcome: no fit for this bounded, plan-only geometry transformation +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-05--parcel-delta-tiles`](../runs/2026-08-05--parcel-delta-tiles) + +### Evidence + +- Commands: + - `cargo test --offline --locked --manifest-path developer-simulation/Cargo.toml -p parcel-delta-tiles` + - `cargo clippy --offline --locked --manifest-path developer-simulation/Cargo.toml -p parcel-delta-tiles --all-targets -- -D warnings` + - `developer-simulation/runs/2026-08-05--parcel-delta-tiles/scripts/verify.sh` + - the archived Rust CLI and TypeScript mirror on a generated 1,000-edit, + 200-distinct-vertex-per-line workload +- Tests: 8 Rust tests passed after strict JSON and topology regressions. +- Demonstration: + - Rust matched the TypeScript full-scan mirror on insertion, deletion, + translation, concavity, holes, MultiPolygon, edge/corner contact, and a + seeded 10,000-edit case. + - An independent analytical rectangle enumerator matched 500 edits and 34,650 + expected tiles without sharing the polygon predicate. + - Ten permutations were byte-identical. Eleven malformed cases, including + recursive duplicate members, self-intersection, zero area, outside holes, + and overlapping holes, failed deterministically with empty stdout. + - The final 7,977,879-byte workload mixed inserts, deletes, replacements, + concave polygons, holes, MultiPolygons, four extents, and 1,000 lines with + 200 distinct vertices each. Rust and the mirror produced the same 2,922 + tile lines. + - On that workload Rust completed in 0.14 seconds with 2,670,592 bytes maximum + RSS. The trial-created TypeScript mirror took 11.99 seconds and 169,312,256 + bytes maximum RSS on the same host and input. +- Baseline comparison: + - No production TypeScript planner existed in the checkout. The archived + TypeScript program is a trial-created correctness mirror with an independent + full-tile enumeration but a shared intersection predicate, so it is not a + fully independent oracle and does not substantiate the brief's reported + production runtime. + - Fold's durable incremental state is unnecessary because each line already + carries authoritative old/new geometry and state access is prohibited. ESE + and ANNy do not solve exact polygon/tile intersection. + +### Friction trail + +The developer followed the public onboarding order, found no parcel baseline, +and avoided the public-example scaffold. Restricted network access made the +first ordinary Cargo run retry the registry, while the cached locked build +worked offline. The initial planner was made explicitly lexicographic and its +fixtures were broadened from rectangles to concavity, holes, MultiPolygons, and +boundary-only contact. + +Skeptical review then defeated ordinary `serde_json::Value` parsing with +duplicate members and showed that bowties, zero-area rings, outside holes, and +overlapping holes were accepted. It also rejected calling the TypeScript mirror +an independent oracle and found the first scale fixture too repetitive. The +coordinator added recursive duplicate rejection and simple-topology admission, +retained the analytical rectangle check as independent evidence, narrowed the +mirror claim, diversified the scale fixture, and reran every check and both +implementations. + +### Findings + +1. **Duplicate JSON members, fixed — correctness defect, high severity, high + confidence.** Reproduction: duplicate `old`, `new`, or nested geometry keys. + Smallest improvement completed: reject duplicates recursively before value + conversion and emit no partial plan. +2. **Invalid polygon topology, fixed — correctness defect, high severity, high + confidence.** Reproduction: self-intersecting or zero-area rings, outside or + overlapping holes, or overlapping MultiPolygon exteriors. Smallest + improvement completed: strict simple-topology validation before planning. +3. **Reference independence overstated, fixed — evidence defect, moderate + severity, high confidence.** Smallest improvement completed: label the + TypeScript program a mirror and separate the analytical rectangle oracle. +4. **All-component scaffold — API friction and documentation gap, moderate + severity, high confidence.** This independently repeats Trial 1. +5. **Authoritative stateless delta planning is poor product fit — informational + severity, high confidence.** Smallest improvement: document the boundary; + adding durable state would make this command less faithful to its contract. +6. **Evidence scope — moderate severity, high confidence.** The implementation + handles strict simple planar GeoJSON polygons on synthetic data, not general + validity, antimeridian wrapping, geodesic edges, or production GIS corpora; + performance is one host and fixture. + +### Decision audit + +The developer chose strict line-at-a-time JSON admission, simple-topology +validation, geometry-bounded candidate enumeration, closed-boundary contact, +deduplication, and lexicographic rendering. Fold, ESE, ANNy, parcel persistence, +rendering/publishing, deletion, spatial indexes, general polygon repair, and +silent malformed-input recovery were rejected. Antimeridian semantics, +geodesics, arbitrary GeoJSON, production topology policy, and multi-host scale +remain unresolved. + +## Skeptical review + +- Claims reproduced: + - Both no-fit decisions; README/example/scaffold discovery; clean FASTQ + comparison; correction/tie/unmatched routing; million-pair counts and + checksum; descriptor bound; named GIS cases; analytical rectangles; + malformed behavior; deterministic permutations; and scale outputs. +- Claims rejected or softened: + - Production-baseline comparisons, broad FASTQ parsing and memory claims, + descriptor evidence before positive-poll enforcement, an independent GIS + oracle, general GeoJSON validity, and multi-host performance. +- Quality fixes required and completed: + - Case-folded and reserved output-name rejection. + - Supported FASTQ identifier validation and reliable external observation. + - Recursive duplicate-member rejection and strict simple-topology admission. + - More varied GIS scale data, nested-workspace integration, root-relative + archive scripts, external generated state, and complete evidence reruns. +- Remaining uncertainty: + - Production inputs and baselines, adversarial record sizes, compressed + sequencing data, storage failures, general GIS validity, antimeridian and + geodesic semantics, and performance beyond the measured host. + +No BogKit correctness defect was demonstrated. The blockers belonged to the two +trial prototypes and were fixed before archival. + +## Cross-run synthesis + +- New evidence: + - Stateless, one-pass exact transformations remain outside the useful public + roles of BogKit when the job has no durable read model, embedding, or + approximate-retrieval need. + - Strict input admission and an independent or analytically separable oracle + are essential before claiming exactness for streaming or geometry tools. +- Recurring evidence: + - The public capability and operational-boundary matrix is now supported by + 18 independent trials. + - The component-selective project path is now supported by 8 trials. + - Nameable pipeline and reader patterns remain supported by 3 trials. +- Candidate improvements: + 1. Public capability and operational-boundary matrix. + 2. Component-selective project path, including a true Fold-only starter. + 3. Documentation for nameable composed pipeline and reader types. +- Observations not yet promoted: + - Strict JSON-member admission, FASTQ parsing, output-spill management, + geometry validity, and tile-intersection utilities. +- No-fit or positioning signals: + - Do not add durable state merely to adopt the toolkit when authoritative + deltas are already present and the result is a bounded exact transformation. + +No new candidate was promoted. + +## Validation + +- Trial-specific tests: all 13 corrected Rust tests and both archived integration + verifiers passed. +- Strict lint and formatting: both new packages passed formatting and Clippy + with warnings denied; the complete nested workspace passed Clippy. The broad + repository formatting check also reports an existing formatting difference in + `examples/search/src/main.rs`, which is outside the automation archive boundary + and was left unchanged. +- Runnable demonstrations: corrected FASTQ clean/mixed/malformed/observer/full + workloads and GIS named/analytical/malformed/permutation/full workloads passed + with the scoped results above. +- Nested lab workspace tests: all 104 tests passed. +- BogKit root workspace tests: all 45 unit and documentation tests passed after + the required ESE model artifact was fetched outside the restricted sandbox. +- Archive and secret checks: `git diff --check`, coverage JSON parsing, marker + uniqueness, changed-path scope, generated-output, database, archive, model, + binary, credential-pattern, symlink, and file-size scans passed. Every intended + changed path is under `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-06.md b/developer-simulation/reports/2026-08-06.md new file mode 100644 index 0000000..a3c2def --- /dev/null +++ b/developer-simulation/reports/2026-08-06.md @@ -0,0 +1,174 @@ +# Developer simulation — 2026-08-06 + + + +Two blind trials tested BogKit against calendar recurrence/time-zone +correctness and HTTP cache revalidation/tag-purge safety. Fresh developers +started from separate sanitized copies of current `main`. Both produced +runnable, reviewed prototypes; neither established a full production fit. +The skeptical review found prototype correctness and evidence blockers, which +were fixed and rerun before archival. No BogKit core or existing-example defect +was demonstrated. + +## Trial 1 — CalDAV recurrence and time-zone correctness + +- Persona: CalDAV calendar maintainer with beginner-to-intermediate Rust +- Existing system: a SQLite-authoritative calendar service with a separate + HTTP/CalDAV layer +- Problem: expand timed and all-day recurrence rules across DST gaps/folds, + exceptions, overrides, deterministic ordering, incremental rebuilds, and + atomic publication for 5,000 masters and 2,000,000 candidate occurrences +- Outcome: no fit for the full production scenario; narrow Fold fit for the + event-master replacement boundary +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold only, for keyed event-master upsert/remove +- Archive: [`caldav-recurrence`](../runs/2026-08-06--caldav-recurrence/README.md) + +### Evidence + +- The standalone prototype has 10 integration tests covering DST gap/fold + policy, all-day and floating values, partial-day intersection, recurrence + rules, exclusions, canonical and unseen override rejection, deterministic + ordering, host time-zone independence, tampered-shard rebuilds, preflight + store integrity, interruption recovery, and malformed-input publication. +- Formatting and warnings-denied Clippy passed. The smoke CLI completed with + 3 events and 4 occurrences. +- A fresh release workload over 5,000 masters and 2,000,000 candidates took + 3.85 seconds and reported 17,547,264 bytes peak RSS. A second run reused all + 5,000 fingerprinted shards in 2.93 seconds with byte-identical output. + Separate `TZ=UTC` and `TZ=Pacific/Honolulu` runs also matched byte-for-byte. +- The assigned 5,000-case oracle, production SQLite service, and named + reference machine were absent, so no production correctness or oracle + agreement is claimed. + +### Friction trail + +The developer followed the public README and examples. `timeseries` ran +through its demo; `starter` and `search` attempted the ESE model download, and +`chat` reached a sandbox listener-bind restriction. Fold's keyed streams, +snapshots, and retractions were useful for event-master replacement, but the +public APIs did not provide recurrence rules, civil-time conversion, SQLite +authority, or publication artifacts. + +The first prototype workload took 30.09 seconds because it fsynced every +per-UID shard and reread all shards to assemble output. The publication path +was changed to one sorted atomic output pass while retaining per-UID recovery +shards, then rerun at the reported 3.85-second result. + +### Findings and decision audit + +The reviewer initially found five correctness blockers: partial-day all-day +queries omitted a touched date, equivalent timed override IDs could overwrite +one another, unseen overrides created phantom occurrences, tampered shards +were trusted, and invalid expansion could mutate the durable event store +before publication. It also found incomplete trial documentation and a stray +`+` in CLI help. The coordinator fixed all of them, added fingerprints and +preflight expansion, and reran the 10-test suite and workload. + +The prototype deliberately keeps SQLite authoritative and uses Fold only for +the event-master boundary. Recurrence semantics, transition tables, shard +integrity, and filesystem publication remain adapter responsibilities. The +oracle, standards completeness, production integration, and process-crash or +power-loss durability remain unresolved. + +## Trial 2 — HTTP cache revalidation and tag-purge safety + +- Persona: edge reverse-proxy maintainer with intermediate Rust +- Existing system: an HTTP cache with separate metadata, content-addressed + bodies, origin revalidation, leases, tag purges, and a byte quota +- Problem: model Vary-aware identity, fresh/stale/stale-if-error decisions, + single-flight revalidation, ordered purges, crash phases, reachability, and + quota behavior for 2,000,000 objects, 1,000,000 requests, and 100,000 purges +- Outcome: no fit for the acceptance-critical cache boundary +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: [`http-cache-revalidation`](../runs/2026-08-06--http-cache-revalidation/README.md) + +### Evidence + +- The dependency-free model has 12 tests covering key normalization, logical + freshness, stale-if-error, single-flight calls, purge fencing and reorder, + modeled crash recovery, quota eviction, stable digests, and strict trace + parsing. +- Formatting and warnings-denied Clippy passed. The demo reported + `purges_applied=1`, `purges_ignored=2`, `recovery_rollbacks=1`, and + `demo=PASS modeled_invariants=true`; the file-driven and quota traces also + passed. Dynamic output uses hashed identifiers and the privacy check passed. +- The compact shape workload reported 2,000,000 objects, 1,000,000 requests, + 100,000 purges, 8.64 GB logical usage under a 64 GiB quota, and 65,634,304 + bytes RSS under the 256 MiB target. It is explicitly a shape check, not a + semantic-scale or production benchmark. + +### Friction trail + +The developer followed the public README and examples. Fold's keyed records, +tag postings, ranking, and single-owner ingest pattern were useful isolated +building blocks, but no public surface covered a durable metadata/body +transaction, per-key lease expiry, distributed workers, or sequence-aware +purge fencing. The trial therefore remained a standalone model rather than a +new cache component hidden behind a BogKit node. + +### Findings and decision audit + +The reviewer found that an older lower-sequence purge could evict a newer +repopulated response, and that the parser silently accepted surplus fields or +more than 16 tags. The coordinator fixed both and reran the affected tests and +traces. Claims were narrowed to a sequential single-process lease model, an +in-memory digest/size/reference model, and a compact quota/memory shape run. + +The prototype does not parse real HTTP, read body bytes, delete files, run a +SQLite transaction, fsync, restart a process, expire leases, recover a lost +worker, or model distributed concurrency. Those are acceptance-critical +boundaries and remain no-fit signals, not BogKit defects. + +## Skeptical review + +- Claims reproduced: both baseline reproductions, final test suites, demos, + purge reorder behavior, recovery traces, privacy output, calendar shard + reuse, tamper rebuilds, preflight store integrity, and stated-count shape + metrics. +- Claims rejected or softened: calendar oracle/SQLite/standards/durability + claims; cache body-file, distributed lease, worker-loss, semantic-scale, and + production performance claims. +- Quality fixes completed: six calendar correctness/documentation issues; + cache purge fencing and strict parser admission; and all affected evidence + reruns. +- Remaining uncertainty: the absent calendar oracle and reference machine, + production adapters, filesystem and power-loss behavior, real HTTP semantics, + durable lease expiry, distributed coordination, and semantic cache behavior + at the stated object count. + +## Cross-run synthesis + +- New evidence: the capability and operational-boundary finding now has 20 + independent trials; the component-selective project-scaffold finding now has + 10; nameable pipeline and reader types remain at 3. +- Recurring evidence: developers repeatedly separate authoritative external + stores, cross-store publication, distributed coordination, strict quotas, + and stateless transformations from BogKit's durable single-writer views. +- Candidate improvements: publish a concise capability/operational-boundary + matrix and make the starter/project path component-selective, including a + true Fold-only introduction. +- Observations not promoted: calendar, cache, parser, lease, body-store, and + recurrence utilities; each remains specific to the trial boundary. +- No-fit signal: adding durable state or a custom cross-store protocol merely + to adopt BogKit would make either prototype less faithful to its contract. + +No new candidate was promoted, and no BogKit core or existing example was +changed. + +## Validation + +- Trial-specific tests: calendar 10 passed; cache 12 passed. +- Strict lint and formatting: both archived packages passed formatting and + warnings-denied Clippy. The complete nested simulation workspace passed + Clippy and tests. The broad formatting check reported the pre-existing + `examples/search/src/main.rs` difference and it was left untouched. +- Runnable demonstrations: both smoke/trace demos and the cache quota trace + passed; the reviewed calendar and cache workloads passed in their fresh + developer checkouts. +- BogKit root workspace tests: passed after the required ESE model artifact + was fetched in the network-enabled retry; all unit and documentation tests + completed successfully. +- Archive and secret checks: run before publication; all committed paths are + restricted to `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-07.md b/developer-simulation/reports/2026-08-07.md new file mode 100644 index 0000000..f95d342 --- /dev/null +++ b/developer-simulation/reports/2026-08-07.md @@ -0,0 +1,215 @@ +# Developer simulation — 2026-08-07 + + + +Two blind developers tested substantially different existing-software +problems from fresh sanitized current-main copies: offline localization catalog +validation and multi-tenant webhook delivery scheduling. A separate skeptical +reviewer reproduced the important claims and found one correctness defect in +each prototype. The coordinator fixed both defects and reran the affected +tests, demonstrations, formatting, and strict lint. No BogKit correctness +defect or recurring BogKit improvement was demonstrated. + +## Trial 1 — Localization catalog compiler + +- Persona: desktop build-tooling developer, intermediate Rust +- Existing system: a Rust CLI loads structured localization catalogs, checks + identifiers and fallback rules, applies an `en-US` baseline, and emits + locale-specific runtime tables. +- Problem: detect missing plural branches, placeholder mismatches, invalid + fallback references, and duplicate IDs while preserving lookup behavior and + deterministic output for 100,000 messages across 18 locales. +- Outcome: partial local proof only; no production adoption decision +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-07--localization-catalog-compiler`](../runs/2026-08-07--localization-catalog-compiler) + +### Evidence + +- Commands: + - `cargo test --offline -p catalog-compiler-prototype --quiet` + - `cargo fmt -p catalog-compiler-prototype -- --check` + - `cargo clippy --offline -p catalog-compiler-prototype --all-targets -- -D warnings` + - `cargo build --offline --release -p catalog-compiler-prototype` + - the archived `catalogc` valid/invalid lookup and compilation commands + - the generated 100,000-record / 18-locale release fixture compiled twice +- Tests: 4 library tests passed after the fallback regression; binary and doc + test targets had no tests. +- Demonstration: + - Valid source and emitted-table lookups returned identical output. + - The invalid fixture returned six diagnostics and continued past the first + error. + - Both corrected stress compiles took 0.05 seconds and reported + 27,010,299 peak live allocated bytes. The output SHA-256 was identical: + `f4d5206bc4dd975da35221f78153ab4897ba9bdf0f3c65620e4bbdee36d5e31a`. +- Baseline comparison: no production catalog corpus or baseline compiler was + available. The prototype grammar and live-allocation measurement are local + evidence, not production ICU/CLDR or RSS claims. + +### Friction trail + +The developer read the public README and starter, timeseries, chat, and search +examples before inspecting source. None exposed localization parsing or table +generation. The clean workspace-wide baseline also encountered ESE's model +download under offline restrictions, while the Fold-local tests passed. The +developer therefore kept the prototype standard-library-only and rejected all +three BogKit components as unrelated to catalog semantics. + +The reviewer then found that a comma-separated fallback list stopped after its +first locale. The coordinator changed lookup to try every fallback, made cycle +validation traverse every fallback edge, added a missing-first/found-second +regression, and reran the complete evidence set. + +### Findings + +1. **Fallback-list traversal, fixed — prototype correctness defect, high + severity, high confidence.** A valid `fallback xx,en-US` chain failed when + `xx` lacked the requested message even though `en-US` supplied it. The + smallest improvement was complete fallback iteration plus a regression. +2. **Production catalog semantics — missing capability/evidence gap, high + severity, low-to-medium confidence.** ICU/CLDR plural rules, escaping, rich + placeholders, and the real catalog format were not present. The smallest + improvement is a differential harness over a sanitized production slice. +3. **Resource evidence — performance/evidence limitation, moderate severity, + high confidence.** Live allocator bytes and local repeated hashes do not + establish RSS, cross-machine reproducibility, or a fair baseline speedup. + +### Decision audit + +The developer chose a dependency-free batch validator, ordered maps, normalized +text output, and a generated-table lookup harness. Fold, ESE, and ANNy were +rejected as mismatched. The narrow idea is a partial local proof, but the +production corpus, runtime semantics, fair baseline, RSS, and second-machine +reproducibility remain unresolved. No component or catalog feature is promoted. + +## Trial 2 — Multi-tenant webhook delivery scheduler + +- Persona: senior Rust backend developer, approximately six years of Rust + experience +- Existing system: PostgreSQL-backed pending webhook deliveries with fixed + per-endpoint queues, exponential retries, dead-lettering, and at-least-once + event IDs for customer deduplication. +- Problem: preserve endpoint ordering, isolate slow endpoints, apply tenant and + endpoint limits, recover after crashes, and avoid retry storms during outage + traces. +- Outcome: no fit for BogKit; corrected standalone scheduler retained only as + an exploratory follow-up +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-07--webhook-scheduler`](../runs/2026-08-07--webhook-scheduler) + +### Evidence + +- Commands: + - `cargo fmt --package webhook-scheduler -- --check` + - `cargo clippy -p webhook-scheduler --all-targets -- -D warnings` + - `cargo test -p webhook-scheduler` + - `cargo test --release -p webhook-scheduler` + - `cargo run --release -p webhook-scheduler -- --repeat 1000` +- Tests: 8 debug tests and 8 release tests passed after the retry-cap + regression. +- Demonstration: + - Ordering, crash requeue, stale-outcome rejection, classification, + fairness, outage recovery, determinism, and hard retry caps passed. + - The repeated release demo completed 1,000 repetitions and reported the + modeled values. Its 15,193 microsecond elapsed time is harness overhead, + not throughput evidence. +- Baseline comparison: the 60,000 ms versus 0 ms healthy-tenant result is a + small formula-versus-in-memory model comparison, not a deployed-worker + latency measurement. No claim is made for the stated production traffic. + +### Friction trail + +The developer inspected the public README and examples before source. They +found durable views and transactional state but no public timer, external-side +effect, lease, or acknowledgement boundary. The scheduler therefore remained +standard-library-only and modeled persistence and HTTP outcomes explicitly. + +The reviewer reproduced a 1,056 ms retry deadline under a 1,000 ms cap because +jitter was added after capping. The coordinator applied the cap after jitter +and added a regression over 1,024 event IDs. The reviewer also narrowed the +outage, durability, fairness, latency, and load claims to the in-memory model. + +### Findings + +1. **Retry cap, fixed — prototype correctness defect, high severity, high + confidence.** Jitter exceeded the configured hard cap. The smallest + improvement was post-jitter capping plus a regression. +2. **External side-effect and durable acknowledgement boundary — poor product + fit, blocker severity, high confidence.** Fold does not supply the modeled + HTTP timer/lease/PostgreSQL acknowledgement boundary. No BogKit component + was used. +3. **Production evidence scope — moderate-to-high limitation, high + confidence.** The prototype did not exercise real HTTP, PostgreSQL, process + or storage durability, payload memory, production-shaped load, or a fair + baseline implementation. + +### Decision audit + +The developer chose endpoint-head ordering, bounded queues, tenant round-robin, +deterministic jitter, and explicit crash/restart transitions. Global FIFO, +random jitter, concurrent endpoint sends, Fold as the dispatch loop, and real +integrations were rejected. The corrected algorithm may justify a future +integration experiment, but it is not production behavior or a dashboard +candidate. + +## Skeptical review + +- Claims reproduced: both no-fit/partial boundaries, focused tests, strict + formatting and Clippy, valid/invalid catalog behavior, repeated catalog + hashes, webhook ordering, crash recovery, outage trace, fairness trace, + classification, determinism, and repeated release demos. +- Claims rejected or softened: production catalog compatibility, cross-machine + determinism, RSS and baseline performance, one-event outage behavior as a + general no-storm claim, in-memory restart as database durability, and + production-scale fairness. +- Quality fixes required and completed: catalog fallback traversal and all-edge + cycle validation; webhook post-jitter hard cap and regression coverage. +- Unnecessary dependencies: none; both prototypes remained dependency-free and + used no BogKit component. +- Remaining uncertainty: real catalog/compiler differential behavior, ICU/CLDR + semantics, RSS and cross-machine checks, HTTP/PostgreSQL durability, payload + memory, production traffic, and operational backpressure. + +No BogKit correctness defect was demonstrated. Neither trial established a +recurring BogKit feature need. + +## Cross-run synthesis + +- New evidence: two additional, orthogonal no-component boundaries—batch + localization semantics and external-effect webhook scheduling. +- Recurring evidence: the public capability/operational-boundary matrix remains + supported by 20 independent trials; component-selective scaffolding by 10; + nameable pipeline/reader patterns by 3. +- Candidate improvements: none newly promoted. The existing capability matrix, + component-selective project path, and nameable pipeline/reader documentation + remain the only threshold-qualified candidates. +- Observations not promoted: catalog compiler support, localization semantics, + webhook timers, durable leases, retry scheduling, and fairness controls. +- No-fit signal: avoid adding durable state or a toolkit component when the + workload's authoritative boundary is external and the required semantics are + exact parsing or external side-effect coordination. + +## Validation + +- Trial-specific tests: catalog 4 library tests; webhook 8 debug and 8 release + tests; all passed after reviewer corrections. +- Strict lint and formatting: both packages passed formatting and Clippy with + warnings denied. +- Runnable demonstrations: valid/invalid catalog runs, two corrected stress + compiles, catalog hash comparison, and 1,000 webhook release repetitions + passed. +- Nested lab workspace: locked offline `cargo test --workspace --offline` and + locked `cargo clippy --workspace --offline --all-targets -- -D warnings` + passed the existing corpus plus the new catalog and webhook packages. +- The broad nested-workspace formatting check still reports pre-existing + differences in `examples/search/src/main.rs`; that unrelated example was + left unchanged. Both new packages pass package-scoped formatting checks. +- BogKit root workspace: `cargo test --workspace` passed all unit and + documentation tests. +- Archive and secret checks: `git diff --check`, `jq empty coverage.json`, + changed-path scope, no-target/database/binary/large-file/symlink checks, and + credential-pattern scans passed. Every changed path is under + `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-08.md b/developer-simulation/reports/2026-08-08.md new file mode 100644 index 0000000..d97c28c --- /dev/null +++ b/developer-simulation/reports/2026-08-08.md @@ -0,0 +1,247 @@ +# Developer simulation — 2026-08-08 + + + +Two blind developers evaluated substantially different existing-software +problems from separate sanitized copies of current `main`: exact medical +remittance reconciliation and legal container-yard reshuffling. Both rejected +BogKit for the core computation. A separate skeptical reviewer found four +blocking prototype/archive defects, eight important evidence or capability +limits, and three minor scope issues. The developers corrected the blocking +prototype defects in two focused rounds; the coordinator completed the archive +transform and reran the final gates. No BogKit correctness defect or new +threshold-qualified BogKit improvement was demonstrated. + +## Trial 1 — Conservative remittance-to-claim reconciliation + +- Persona: revenue-cycle platform engineer, production Java and SQL expert, + Rust beginner +- Existing system: a Java/PostgreSQL nightly process normalizes X12 835 data, + tries exact insurer references, then uses greedy SQL fallback matching. +- Problem: match 50,000 remittance lines against 62,000 current claim revisions + without double posting, cent loss, input-order dependence, or silent guesses. +- Outcome: no fit for BogKit; corrected standalone bounded matcher retained as + a local research prototype +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-08--remittance-reconciliation`](../runs/2026-08-08--remittance-reconciliation) + +### Evidence + +- Commands: + - `cargo fmt -p remittance-reconciliation -- --check` + - `cargo test --offline --locked -p remittance-reconciliation --all-targets` + - `cargo clippy --offline --locked -p remittance-reconciliation --all-targets --all-features -- -D warnings` + - the archived demo, overlapping-identity CLI/verifier reproduction, and + `scripts/run-acceptance.sh` +- Tests: 6 library and 17 adversarial tests passed. These cover contradictory + identity sources, duplicate logical rows, staged comparator behavior, + conservative split/reversal scope, privacy checks, deterministic output, and + bounded-search exhaustion. +- Demonstration: the final archived 62,000-claim / 50,000-remittance run linked + all 49,995 authored unambiguous truth links with no verifier or privacy + failures in 4.628 seconds. Ten fixed shuffles were byte-identical and ran in + 4.779–5.093 seconds. +- Baseline comparison: the corrected exact-reference-first greedy comparator + produced 99.903944% precision, 99.855986% recall, and 12 obsolete-revision + failures on the authored generator. The production Java/SQL executable, + external fixture, independent truth, supplied seeds, specified hardware, and + peak resident memory were unavailable, so these are not production claims. + +### Friction trail + +The developer started with the public README and all four examples. Fold's +durable incremental views did not supply the required bounded global +assignment over one immutable snapshot; ESE and ANNy's approximate similarity +was unsafe for exact identifiers and integer money. The developer therefore +built a small standalone Rust solver with review-on-uncertainty output. + +The first reviewer pass found that contradictory exact-reference and fallback +identities could silently choose one source, duplicate logical IDs could create +capacity, the comparator was not truly reference-first, and split/reversal and +generator evidence were overstated. Round one fixed duplicates and staging and +narrowed the claims, but a partial-overlap `{A,B}` versus `{B,C}` case still +selected a reference-only candidate. Round two restricted all single and split +allocations to the intersection when both sources exist. The reviewer reran the +original counterexample, permanent fixture, controls, tests, lint, formatting, +and demo, then approved archival with no new important regression. + +### Findings + +1. **Conflicting identity admission, fixed — prototype correctness defect, + blocker severity, high confidence.** Nonempty reference and fallback sets + now permit only shared candidates; infeasible disagreement is reviewed. +2. **Duplicate logical rows, fixed — prototype correctness defect, blocker + severity, high confidence.** Every physical duplicate remittance ID or + claim `(id, revision)` row is quarantined before indexing and cannot create + capacity. +3. **Settlement scope — missing prototype capability, important severity, high + confidence.** Only full-open-balance same-claim subsets and same-snapshot + offset reversals are supported; partial allocations and standalone + reversals require upstream authority and remain review-only. +4. **External validity — evidence limitation, important severity, high + confidence.** Generator truth is authored by the same prototype, and the + production comparator, real fixture, memory measurement, and domain + validation are absent. + +These are prototype and evaluation findings, not BogKit defects. A generic +matching or flow primitive is a one-trial observation and is not promoted. + +### Decision audit + +The developer chose exact integer cents, highest-revision current claims, +complete exact fallback identity, joint bounded cluster search, intersection +of conflicting identity sources, full-balance-only splits, conservative +reversal handling, a one-million-node ceiling, and canonical review output. +Approximate matching, stable tie guessing, arbitrary many-to-many flow, and +BogKit persistence were rejected. The highest-revision, 31-day fallback range, +duplicate, split, reversal, and X12 normalization policies still need explicit +production authority. + +## Trial 2 — Advisory container-yard reshuffling + +- Persona: terminal-operating-system integration developer, production C# and + SQL experience, Rust novice +- Existing system: a nearest-legal-slot greedy helper proposes relocations for + urgent pickups from a normalized yard snapshot. +- Problem: produce deterministic legal move sequences while respecting stack + height, weight, reefer, customs, maintenance, hazardous-neighbor, pickup + order, and no-partial-publication constraints. +- Outcome: no fit for BogKit; corrected standalone conservative proposal + generator retained with a known feasible false negative +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-08--container-yard-planner`](../runs/2026-08-08--container-yard-planner) + +### Evidence + +- Commands: + - `cargo fmt -p container-yard-planner -- --check` + - `cargo test --offline --locked --release -p container-yard-planner --all-targets` + - `cargo clippy --offline --locked -p container-yard-planner --all-targets --all-features -- -D warnings` + - the archived release demo and focused output-reuse, verifier, timeout, and + exact-feasible-witness tests +- Tests: 1 library and 15 acceptance tests passed. They cover placement rules, + canonical output, replay, dishonest metadata, atomic exclusive publication, + reused-output transitions, injected failure, timeout, and the preserved + feasible false negative. +- Demonstration: one micro-geometry used 3 baseline relocations versus 2 + lookahead relocations (33% for that geometry), replayed successfully, and was + deterministic. A dense 48-by-6, 1,280-container fixture observed 42 versus 41 + relocations and completed in roughly 8–16 ms in prior release runs. +- Baseline comparison: 27 suffix-renamed cases repeat one geometry, not 27 + diverse feasible snapshots. The stated 30 production files, C# results, + production 20% aggregate, specified laptop, and hard end-to-end 10-second + guarantee were unavailable or not established. + +### Friction trail + +After reading the public README and examples, the developer found no BogKit +component related to deterministic constraint search over a frozen snapshot. +Fold persistence, text embeddings, and nearest-neighbor search would not prove +move legality, so the prototype remained standalone and advisory. + +The reviewer found a legal three-stack sequence that the bounded heuristic +missed, incomplete verification of output metadata, and a reused-output bug +that could leave an old executable `moves.json` beside a new review or failed +run. The developer preserved the false-negative witness, narrowed feasibility +and timing claims, strengthened full-artifact verification, and implemented +atomic exclusive current-result publication with failure cleanup. The reviewer +reproduced the fixes and approved archival. + +### Findings + +1. **Stale executable artifact, fixed — prototype correctness defect, blocker + severity, high confidence.** Success, review, malformed input, timeout, + replay rejection, and injected write failure now leave exactly one valid + current result or none. +2. **Heuristic incompleteness — missing prototype capability, important + severity, high confidence.** A preserved three-stack witness has a legal + sequence that the eight-pickup bounded heuristic misses. Review therefore + means “no proposal found,” not “infeasible.” +3. **Verifier independence and scope — evidence limitation, important + severity, high confidence.** The verifier now checks every published field + and separately replays transitions, but shares static snapshot and hazard + definitions with the planner. +4. **Production comparison — evidence limitation, important severity, high + confidence.** The repeated micro-case and one dense synthetic block do not + establish the requested production completion, improvement, or hard + deadline. + +These are prototype and evaluation findings, not BogKit defects. A generic +state-space planner is a one-trial observation and is not promoted. + +### Decision audit + +The developer chose an advisory file boundary, a private cloned state, +deterministic eight-pickup lookahead, reciprocal hazardous-neighbor validation, +separate transition replay, and generation-safe atomic publication. Exhaustive +optimal search, random search, partial move prefixes, live terminal integration, +and BogKit dependencies were rejected. The constraint model, deadline boundary, +and production snapshot translation remain uncertain. + +## Skeptical review + +- Claims reproduced: both narrow no-fit decisions, focused test suites, strict + formatting and lint, runnable demos, authored remittance acceptance and + shuffles, remittance overlap and duplicate counterexamples, yard publication + lifecycle, verifier corruption rejection, and the feasible false negative. +- Claims rejected or softened: broad remittance safety before the fixes, + general split/reversal support, production accuracy and speed, complete yard + feasibility, 27-case diversity, production relocation improvement, fully + independent verification, and a hard end-to-end 10-second bound. +- Quality fixes required and completed: contradictory identity intersection, + duplicate-row quarantine, staged comparator semantics, atomic exclusive yard + result publication, complete metadata verification, and archive workspace + normalization. Narrow claims were corrected instead of adding subsystems. +- Unnecessary code or dependencies: none. Each archive uses only `serde` and + `serde_json`; no BogKit, solver, async, database, network, or ML dependency + was added. +- Remaining uncertainty: production data and baselines, independent truth, + memory, X12/domain policy, exact planning completeness, shared verifier + definitions, and end-to-end timing. + +The initial audit counted 4 blockers, 8 important findings, and 3 minor issues. +After two focused fix rounds, the same reviewer approved both archives and +found no new critical or important breakage. No BogKit correctness defect was +demonstrated. + +## Cross-run synthesis + +- New evidence: two additional no-component boundaries—exact global assignment + over an immutable financial snapshot and deterministic constraint planning + over a frozen operational snapshot. +- Recurring evidence: no existing recurring-finding count changes. The public + capability/operational-boundary matrix remains supported by 20 independent + trials; component-selective scaffolding by 10; nameable pipeline/reader + patterns by 3. +- Candidate improvements: none newly promoted. The existing capability matrix, + component-selective project path, and nameable pipeline/reader documentation + remain the only threshold-qualified candidates. +- Observations not yet promoted: exact reconciliation/flow machinery and a + general state-space planner. They are distinct one-trial capabilities and + should not be combined into a vague solver subsystem. +- No-fit signal: BogKit should not be added merely to persist or approximate a + frozen exact computation. Keeping narrow domain logic outside the toolkit is + preferable to product bloat. + +## Validation + +- Trial-specific tests: remittance 23 total; yard 16 total; all passed after + reviewer-directed corrections. +- Strict lint and formatting: both new packages passed package formatting and + strict Clippy with warnings denied. +- Runnable demonstrations: remittance demo, permanent overlap CLI/verifier, + full authored acceptance and ten shuffles; yard demo and focused acceptance + suite all passed from the final archive paths. +- Nested lab workspace: locked offline metadata resolved both new packages + under the single nested workspace and lockfile; locked release tests and + strict Clippy passed the complete archived corpus. +- BogKit root workspace: `cargo test --workspace` passed all unit and + documentation tests. +- Archive and secret checks: `git diff --check`, valid coverage JSON, + changed-path scope, no child workspace/lockfile, target/database/binary/large- + file/symlink checks, and credential-pattern scans passed. Every changed path + is under `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-09.md b/developer-simulation/reports/2026-08-09.md new file mode 100644 index 0000000..bcb857c --- /dev/null +++ b/developer-simulation/reports/2026-08-09.md @@ -0,0 +1,266 @@ +# Developer simulation — 2026-08-09 + + + +Two blind developers evaluated substantially different existing-software +problems from separate sanitized copies of current `main`: exact incremental +syntax highlighting for a large-file desktop editor and exact ordered firewall +policy impact analysis. Both concluded that BogKit was not a fit for the core +computation and built standalone local prototypes. A separate skeptical +reviewer found four blocking prototype/archive defects, nine important evidence +or capability limits, and three minor issues. Three focused fix rounds closed +the prototype defects and strengthened the permanent regressions; the +coordinator normalized and reran the final archives. No BogKit correctness +defect or new threshold-qualified BogKit improvement was demonstrated. + +## Trial 1 — Incremental syntax highlighting without stale tokens + +- Persona: desktop editor maintainer with three years of production Rust + experience and no prior incremental-lexer implementation +- Existing system: a correct full-document UTF-8 lexer replaces all displayed + token spans after each edit, causing visible delays on large generated files +- Problem: preserve exact full-lexer tokens, byte ranges, state, and diagnostics + while relexing only the affected suffix when convergence is proven +- Outcome: no fit for BogKit; corrected standalone line-cache prototype retained + as a local proof, with peak-memory and production-editor claims unproved +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-09--incremental-syntax-highlighting`](../runs/2026-08-09--incremental-syntax-highlighting) + +### Evidence + +- Commands: + - `cargo fmt --check -p incremental-syntax-highlighting` + - `cargo test --offline --locked --release -p incremental-syntax-highlighting --all-targets` + - `cargo clippy --offline --locked --release -p incremental-syntax-highlighting --all-targets --all-features -- -D warnings` + - the archived `correctness`, `adversarial`, exact cross-line `apply`, full + oracle `benchmark`, and `reproducibility` commands +- Tests: 5 library and 1 binary test passed. The permanent regressions cover + invalid-edit nonmutation, EOF cache partitioning, the exact cross-line String + token, and canonical result/counter serialization. +- Differential evidence: 100 generated documents and 10,000 edits matched a + fresh full lex exactly; 9 handwritten cases with 19 accepted and 3 rejected + edits passed. The reviewer also ran an independently generated 250-document / + 100,000-edit corpus plus four invalid-coordinate cases without disagreement. +- Large demonstration: the normalized archived binary checked all 2,000 edits + on a 10,389,803-byte / 200,000-line document against a fresh full lex. All + 1,900 localized edits met both locality thresholds; the incremental lexer + scanned 415,888,298 bytes versus 20,779,606,000 full-relex bytes, or + 2.001425%. The oracle run took 72,696 ms on this machine. +- Determinism: two large seeded reruns byte-compared equal 20,576,672-byte + canonical `(LexResult, WorkCounters)` values; stable measurement fields also + matched exactly. +- Memory boundary: 61,589,867 bytes (58.7367 MiB) is an internal retained-index + capacity estimate only. It excludes transient allocations, fragmentation, + and process RSS, so the requested 64 MiB peak-memory criterion did not pass. + +### Friction trail + +The developer read the public README and all four examples before choosing a +component. ESE and ANNy were immediate no-fits; Fold remained plausible until +the baseline made the missing primitive explicit: exact positional edits, +cross-line lexical state, byte-range rebasing, and proven suffix convergence, +not durable record materialization. The developer kept the authoritative full +lexer as the oracle and built the smallest line-oriented cache around it. + +The skeptical reviewer then found a valid EOF-reaching UTF-8 edit for which the +cache retained an extra terminal line and emitted String token ranges `5..14` +and `14..15`, while fresh full lex emitted one `5..15` token. The developer +fixed terminal-line ownership, added the exact token and canonical line-count +regression, and reran every differential and oracle workload. Review also +corrected an overstated memory claim and replaced digest-only reproducibility +with exact serialized-value comparison. The final reviewer fuzz, focused +tests, formatting, lint, oracle benchmark, and reproducibility checks passed. + +### Findings + +1. **EOF splice token split, fixed — prototype correctness defect, blocker + severity, high confidence.** The permanent regression now asserts the exact + three-line cache and one String token at bytes `5..15`. +2. **Peak memory — evidence limitation, important severity, high confidence.** + The deterministic 58.7367 MiB retained estimate does not bound actual peak + process memory; the full acceptance criterion remains unverified. +3. **Counter scope — performance-evidence limitation, important severity, high + confidence.** The 2.001425% ratio measures lexer bytes, not the flat + line-start rebuild, suffix rebasing, total CPU, or editor latency. +4. **External validity — evidence limitation, important severity, high + confidence.** Incremental equality is strong for the prototype-authored + lexer but does not establish compatibility with the production editor's + lexer or token grouping. + +These are prototype and evaluation findings, not BogKit defects. A generalized +incremental parsing or ordered-sequence subsystem is a one-trial observation +and is not promoted. + +### Decision audit + +The developer chose exact UTF-8 byte coordinates, a canonical line cache, +transactional invalid-edit rejection, content/state/token convergence rather +than hash trust, full-lexer fallback, internal work counters, and exact +serialized reproducibility. Persistence, embeddings, approximate neighbors, a +general parser framework, and production-editor integration were rejected. A +production text buffer, allocator-backed peak measurement, and the real editor +lexer remain necessary before any shipping claim. + +## Trial 2 — Ordered firewall policy impact analyzer + +- Persona: network release-tooling engineer, senior in network software, Rust + beginner with substantial production Go experience +- Existing system: release preflight validates syntax and replays sampled recent + packet metadata against old and proposed first-match policies +- Problem: compute exact newly allowed/denied regions and proposed-rule + reachability, with deterministic replayable witnesses, without private logs +- Outcome: no fit for BogKit; corrected standalone exact analyzer retained with + deliberately narrow modeled semantics and output-sensitive performance claims +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-09--firewall-policy-impact`](../runs/2026-08-09--firewall-policy-impact) + +### Evidence + +- Commands: + - `cargo fmt --check -p firewall-policy-impact` + - `cargo test --offline --locked --release -p firewall-policy-impact --all-targets -- --nocapture` + - `cargo clippy --offline --locked --release -p firewall-policy-impact --all-targets --all-features -- -D warnings` + - archived baseline sampling, two byte-compared exact analyses, strict verify, + malformed-input no-output check, `validate-suite`, and 50,000-rule run +- Tests: 14 acceptance tests passed. They cover the handwritten policy cases, + 4,560,000 differential packet checks/probes, reserved implicit identity, + deterministic output, closed report schema, stale-output cleanup, five input + alias forms, hard-link/symlink temp attacks, collision retry, and atomic + publication. +- Baseline comparison: the two synthetic sampled packets saw no change, while + exact analysis found `10.0.1.0/24`, TCP ports 443–444, newly allowed by + `new-admin-range`. Its change witness and both reachability witnesses replayed + with the separate linear evaluator. The sample is illustrative, not a claim + about the stated real incident. +- Differential evidence: 10,000 reduced-universe pairs covered 2,560,000 packet + tuples exhaustively; 1,000 full-width IPv4/IPv6 pairs added 2,000,000 + boundary probes. There were zero disagreements with the separate linear + evaluator. +- Large demonstration: after archive profile normalization, exact analysis of + the recorded 50,000/50,000-rule fixture took 0.13 seconds and verification of + 1 change plus 25,000 reachability witnesses took 0.81 seconds. The archive + sandbox withheld RSS. Earlier declared-host standalone runs observed + 0.12–0.13 seconds and at most 41,664,512 bytes RSS; that memory result is not + attributed to the normalized archived binary. + +### Friction trail + +The README and examples showed that Fold persists incremental materialized +views, ESE embeds text, and ANNy performs approximate neighbor search. None +replaces a one-shot exact comparison of ordered CIDR/protocol/port predicates, +so the developer rejected all three and implemented a self-contained exact +sweep plus a separate simple reference evaluator. + +The reviewer found three publication-safety defects across three rounds. A real +rule could impersonate the implicit `default-deny` marker; failed reruns could +leave a stale valid report; output aliases could overwrite an input; and a +predictable temporary path could follow a hostile hard link or symlink and +truncate a policy. The developer reserved the marker, closed the report schema, +made failure cleanup explicit, rejected direct/canonical/symlink/hard-link input +aliases before unlinking, and changed publication to exclusively create and +sync an owned temporary file before atomic rename. The reviewer's independent +hard-link attack and all permanent regressions then passed. + +### Findings + +1. **Input and temporary-path overwrite risks, fixed — prototype correctness + defects, blocker severity, high confidence.** Input aliases are rejected and + temporary candidates are exclusively created with collision retry; both + inputs and foreign collision files remain byte-identical. +2. **Stale verdict and implicit-marker ambiguity, fixed — prototype correctness + defects, important severity, high confidence.** Failed reruns remove the + requested report, and `default-deny` is reserved for the implicit decision. +3. **Strict report verification, fixed — prototype strictness defect, minor + severity, high confidence.** Unknown fields at every report layer now fail + before `exact_report=true` can be printed. +4. **Output sensitivity — performance-evidence limitation, important severity, + high confidence.** The recorded fixture has one normalized change region; + adversarial highly fragmented exact output may require substantially more + time and memory. +5. **External validity — evidence limitation, important severity, high + confidence.** Exactness covers the toy source-CIDR/protocol/destination-port + model, not a vendor policy language or the production evaluator. + +These are prototype and evaluation findings, not BogKit defects. An exact +static firewall analyzer is a domain-specific one-trial capability and is not a +proposal for BogKit to add a firewall subsystem. + +### Decision audit + +The developer chose strict complete input admission, exact first-match geometry, +implicit default deny, deterministic lower-corner witnesses, a separately +written linear evaluator, closed report schemas, read-only policy handling, +exclusive temporary creation, and atomic report publication. Sampling, +approximation, persistence, policy rewriting, deployment decisions, and vendor +feature expansion were rejected. Production syntax, fragmentation limits, +report-size policy, and external evaluator parity remain uncertain. + +## Skeptical review + +- Claims reproduced: both no-fit decisions; exact lexer equality after the EOF + fix; an independent 100,000-edit lexer corpus; invalid-edit nonmutation; + exact serialized reproducibility; firewall exhaustive/probe agreement; + witness replay; deterministic reports; strict invalid-input behavior; + publication safety; and both recorded large fixtures. +- Claims rejected or softened: Trial 1 peak memory, whole-editor performance, + and production-lexer compatibility; Trial 2 universal 50,000-rule resource + bounds, real-vendor semantics, and the illustrative sample as incident proof. +- Quality fixes required and completed: canonical EOF splice ownership, exact + serialization comparison, reserved fallback identity, stale-output cleanup, + complete report schema, input-alias protection, and exclusive collision-safe + temporary publication. +- Unnecessary code or dependencies: none. Each package uses only `serde` and + `serde_json`; no BogKit, database, network, async, parser, solver, or ML + dependency was added. +- Remaining uncertainty: real editor and firewall oracles, true Trial 1 peak + memory, uncounted line-index work, production latency, adversarial firewall + output fragmentation, archive-path RSS, and domain-policy completeness. + +The final audit counted 4 blocker/critical, 9 important, and 3 minor findings, +including fixed discoveries and coordinator-only archive conditions. After +three focused correction rounds, the same reviewer approved both archives. No +BogKit correctness defect or new candidate was demonstrated. + +## Cross-run synthesis + +- New evidence: two additional no-component boundaries—stateful positional + suffix convergence over an edited text buffer, and exact one-shot geometry + over an ordered policy snapshot. +- Recurring evidence: no existing recurring-finding count changes. The public + capability/operational-boundary matrix remains supported by 20 independent + trials; component-selective scaffolding by 10; nameable pipeline/reader + patterns by 3. +- Candidate improvements: none newly promoted. Trial 2's starter-dependency and + closure-type observations do not add an independently audited source to the + existing candidates. +- Observations not yet promoted: an in-memory ordered-sequence convergence + primitive and exact static-policy analysis. They are separate domain-specific + needs and should not be combined into a vague new subsystem. +- No-fit signal: exact local algorithms over one complete snapshot or buffer + should remain outside BogKit when persistence, embeddings, and approximate + neighbors do not simplify the load-bearing correctness work. + +## Validation + +- Trial-specific tests: syntax 6 total; firewall 14 total; all passed after + reviewer-directed fixes from the normalized final archive paths. +- Strict lint and formatting: both new packages passed focused formatting and + strict release Clippy with warnings denied. +- Runnable demonstrations: syntax correctness, adversarial, cross-line CLI, + full 2,000-edit oracle, and exact reproducibility passed; firewall sampling, + byte-deterministic baseline, strict verification, invalid no-output, + differential suite, and 50,000-rule analyze/verify passed. +- Nested lab workspace: the two child workspaces, child lockfiles, and ignored + package release profiles were removed. The single nested lock resolved both + packages offline; locked release tests and strict Clippy passed the complete + archive workspace. +- BogKit root workspace: `cargo test --workspace` passed all 45 unit and + documentation tests. +- Archive and secret checks: `git diff --check`, valid coverage JSON, + changed-path scope, child workspace/lockfile, build/database/binary/large-file/ + symlink scans, and credential-pattern scans passed. Every changed path is + under `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-10.md b/developer-simulation/reports/2026-08-10.md new file mode 100644 index 0000000..357cda5 --- /dev/null +++ b/developer-simulation/reports/2026-08-10.md @@ -0,0 +1,246 @@ +# Developer simulation — 2026-08-10 + + + +Two blind developers evaluated substantially different existing-software +problems from separate sanitized copies of current `main`: a bilingual +support-case duplicate finder beside authoritative PostgreSQL, and a fully +offline repair-cafe lending ledger. Both built working BogKit integrations, +but neither justified production adoption over its existing baseline. A +separate skeptical reviewer confirmed one narrow Fold correctness defect and +found one important prototype ordering bug plus three minor evidence/reporting +issues. The original developer fixed the prototype bug and both archives passed +fresh normalized-workspace checks. No BogKit core or existing example changed. + +## Trial 1 — Bilingual support-case duplicate finder + +- Persona: customer-support platform developer with production TypeScript + experience and six months of Rust +- Existing system: PostgreSQL full-text search over a privacy-scrubbed + projection of resolved support cases +- Problem: improve English/Spanish duplicate-case recall without moving + authority, permissions, or workflow ownership out of PostgreSQL +- Outcome: local mechanics proof only; no production fit on current evidence +- BogKit components considered and used: Fold, ESE, and ANNy +- Archive: + [`runs/2026-08-10--support-case-finder`](../runs/2026-08-10--support-case-finder) + +### Evidence + +- Quality commands: + - `cargo fmt -p support-case-finder -- --check` + - `cargo test --offline --locked --release -p support-case-finder --all-targets` + - `cargo clippy --offline --locked --release -p support-case-finder --all-targets -- -D warnings` +- Tests: 2 unit tests passed; the separate panic reproducer compiled and the + complete nested workspace test suite also passed. +- Disclosed retrieval fixture: lexical, semantic, and hybrid each returned a + known case for 6/6 queries at rank 5. At the more discriminating rank 1, + lexical scored 5/6, semantic 4/6, and hybrid 5/6. The hybrid therefore did + not improve on lexical even in the toy comparison. +- Mechanics: three reopened processes emitted byte-identical ordered IDs; an + aborted child left the prior index queryable; retry made an edit and insert + visible and removed a deletion; every displayed excerpt was an exact + substring of approved fixture text. +- Normalized archive scale run: 75,000 synthetic cases built and checkpointed + in 80.43 seconds, occupied 321,337,660 bytes, applied 2,000 mixed changes in + 1.78 seconds, and served the repeated warm query at 17.24 ms p95. Developer + and reviewer runs observed 254.2–321.3 MB, so storage is reported as a range, + not a stable size. The host was not constrained to two cores and peak memory + was unavailable. +- Primary acceptance gate: not run. The private 200-query English/Spanish + judgments and the production 58% recall@5 baseline implementation were not + available, so the required 72% recall@5 gain remains unestablished. + +### Friction trail + +The public search example made Fold, ESE, and ANNy look unusually direct for a +hybrid side index. The first offline Cargo build still attempted ESE's network +download until an existing model/tokenizer cache was supplied. Source +inspection then showed that the embedded model is English-specific, while the +brief is explicitly bilingual. The developer used a small disclosed fixture +and retained PostgreSQL as authority rather than turning synthetic success into +a production claim. + +Crash testing uncovered the strongest result. A child-process abort during an +uncommitted refresh preserved the old index and allowed retry. But if the +service catches a panic resumed by Fold's `wtx`, the committed row remains +readable and the next write panics with Fjall's `poisoned tx lock`. The +reviewer independently reproduced the minimal expected-failure binary and +traced the cause to resuming the user panic while the transaction's mutex guard +is still alive. + +### Findings + +1. **Caught write panic poisons later writes — BogKit correctness defect, + important severity, high confidence.** Run + `cargo run --offline --locked --release -p support-case-finder --bin panic_poison`; + it exits 101 after proving rollback and then prints `poisoned tx lock`. The + smallest correction is to drop the underlying write transaction before + resuming the panic and add both `Stream` and `KeyedStream` regressions that + prove rollback plus a successful later write. +2. **ESE model and offline-build boundaries — documentation gap, important + severity, high confidence.** The public onboarding does not name the model, + its English scope, cache path, first-build network behavior, or a hermetic + build recipe. Document those facts before considering a model-selection API. +3. **Named pipeline reuse — API friction, important severity, high + confidence.** Closure-bearing pipeline types again forced macros around + ordinary open/search helpers. This independently strengthens the existing + documentation-first candidate; it does not justify type erasure. +4. **Production retrieval and resource gates — evidence limitation, important + severity, high confidence.** Synthetic text, an unconstrained host, missing + peak memory, fixed top-K candidate behavior, and absent private judgments + prevent an adoption claim. + +### Decision audit + +The developer chose ESE `dim-64`/`quant-8`, default HNSW tuning with seed 42, +default ASCII-oriented BM25, equal-weight reciprocal-rank fusion, application +sorting by case ID, unweighted concatenated fields, fixed exact excerpts, and +one large daily transaction. Language/product filtering, candidate-cutoff tie +completeness, versioned index swapping, memory ceilings, concurrent refresh +traffic, and a restart policy remain unresolved. The local index is replaceable +and advisory; PostgreSQL remains the only authority. + +## Trial 2 — Repair-cafe tool lending kiosk + +- Persona: sole volunteer developer with strong Python/SQL and intermediate + Rust experience +- Existing system: CSV event files plus a Python-generated current-inventory + snapshot +- Problem: keep ordered history and current state atomic, replayable, + inspectable, searchable, and fully offline on one small laptop +- Outcome: no fit for the authoritative store; Fold is a successful local + proof, but embedded SQL remains the better production choice +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold only at the application level +- Archive: + [`runs/2026-08-10--repair-cafe-kiosk`](../runs/2026-08-10--repair-cafe-kiosk) + +### Evidence + +- Quality commands: + - `cargo fmt -p repair-cafe-fold-trial -- --check` + - `cargo test --offline --locked --release -p repair-cafe-fold-trial --all-targets` + - `cargo clippy --offline --locked --release -p repair-cafe-fold-trial --all-targets --all-features -- -D warnings` +- Tests: 6 tests passed, including transition/rejection atomicity, reopen and + historical queries, canonical lookup, live order guards, and matching import + order guards. +- Process interruption: a child flushed pending event and projection views + inside an uncommitted transaction, then aborted. Reopen exposed the complete + old state; a later committed event reopened with both new views. +- Normalized archive benchmark: 8,000 items, 1,200 borrowers, and 100,000 + accepted events imported in 1.18 seconds; five injected bad rows were all + reported; reopen took 0.198 seconds; 1,000 point reads measured 0.003 ms p95; + the synthetic search set scored 50/50 in 3.53 seconds; three fresh processes + reproduced byte-identical current views. Peak RSS was 172.0 MiB for the + coordinator and 171.3–172.8 MiB for replay children on this host. +- Baseline comparison: not benchmarked. Embedded SQL still provides familiar + uniqueness/foreign-key constraints, indexed ordered history, inspection, + backup, and recovery; the report does not invent comparative speed or size. + +### Friction trail + +Fold genuinely kept accepted events and the current/history projection in one +transaction, while ordinary deterministic matching made ESE and vector search +unnecessary. However, a Fold-only application still resolved ANNy through +Fold's unconditional dependency. Naming the reusable pipeline and reader also +required about forty lines of nested aliases and function-pointer casts. + +The reviewer found that the first implementation validated a return against +current state, then sorted all history by caller-supplied sequence during +materialization. Checkout sequence 10 followed by return sequence 5 therefore +passed preflight and panicked during replay. The developer chose an explicit +global append-only policy, rejecting reused or non-increasing sequences before +`wtx` in both live writes and imports. Permanent regressions prove both errors +leave state/history unchanged and that a later valid write still succeeds. The +small proof scans all events for this check; that O(total events) cost is +disclosed rather than hidden behind a new subsystem. + +### Findings + +1. **Out-of-order event panic, fixed — prototype correctness defect, + important severity, high confidence.** Strict append-order validation and + two permanent regressions close the hole without changing BogKit core. The + no-fit recommendation remains. +2. **Fold-only consumers still pull ANNy — API/packaging friction, important + severity, high confidence.** `cargo tree` independently confirmed + application → Fold → ANNy even though the kiosk rejects vector search. This + strengthens the existing component-selective candidate; the narrowest + improvement is an optional HNSW/ANNy integration with a simple default. +3. **Durability and validation boundaries — documentation gap, important + severity, high confidence.** Public onboarding should distinguish process + atomicity, checkpoint/power-loss durability, transactional reads, and why + pre-transaction validation depends on the stated single-writer rule. +4. **Relational ledger — poor product fit, high confidence.** Fold can perform + the workload, but it moves uniqueness, references, ordering, inspection, + and recovery into bespoke Rust without demonstrating an advantage over the + persona's embedded-SQL baseline. Do not add relational or SQL-like machinery + to BogKit based on this trial. + +### Decision audit + +The developer chose one global strictly increasing sequence, append-only +correction events, application validation, a per-item history vector, day-level +time, checkpoint after bulk work, and an O(8,000) normalized edit-distance +search. Backdated insertion, master-data mutation, real CSV parsing, live-copy +backup, corruption/version migration, power loss, production volunteer query +logs, and the actual old laptop remain untested. The full-line/history rewrite +and O(total-events) order check are proof-scale choices, not production advice. + +## Skeptical review + +- Claims reproduced: both narrow non-production outcomes; Fold multi-view + atomicity; keyed edit/delete and process reopen; deterministic output; + exact-source/privacy shape; the caught-panic writer poisoning; 75,000-case + scale mechanics; kiosk rejection nonmutation; transaction abort/reopen; + 100,000-event import; exact replay; lookup; memory; and the transitive ANNy + dependency. +- Claims rejected or softened: production retrieval gain, Spanish embedding + quality, stable storage size, declared-host performance, Trial 1 peak memory, + every crash boundary, real kiosk search language, power-loss durability, and + measured superiority over embedded SQL. +- Review count: 0 blocker/critical, 2 important, and 3 minor findings. One + Important is the confirmed Fold defect; the other is the fixed kiosk order + bug. Minor corrections covered transitive-dependency wording, generated-data + location, and the exact boundary exercised by Trial 1's abort check. +- Final verdict: both archives approved after the original kiosk developer's + correction and an independent scoped re-review. + +## Cross-run synthesis + +- New confirmed defect candidate: Fold's caught-`wtx` writer poisoning meets + the one-serious-reproducer threshold. The proposal is only the drop-before- + resume correction and two regression tests; the lab does not modify core. +- Recurring storage/product-fit boundary: 22 independent trials now support a + concise public capability matrix. Today's two cases distinguish a replaceable + advisory side index from a relational source of truth. +- Component-selective setup: 11 independent sources. The kiosk adds direct + evidence that choosing Fold alone still brings unused ANNy code. +- Nameable pipelines/readers: 5 independent sources, now high confidence. + Keep the candidate documentation-first: show aliases, function pointers, + macros, and ordinary helper signatures before considering a new abstraction. +- Observation not yet promoted: ESE model provenance, English-only suitability, + and first-build download behavior deserve documentation, but this is the + first archived developer trial that selected ESE. Multilingual model choice, + metadata filtering, index swapping, relational constraints, and SQL-like + range queries remain one-scenario needs or explicit no-fit boundaries. +- Outcome totals: 28 trials — 25 `no_fit` and 3 + `local_proof_only_no_production_fit`. + +## Validation + +- New packages: 8 focused tests passed; formatting and strict release Clippy + passed with warnings denied. +- Runnable evidence: support retrieval/update/crash/determinism/privacy demo, + 75,000/2,000 scale run, expected writer-poisoning reproducer, kiosk demo, + transaction-interruption run, and 100,000-event benchmark all ran from the + normalized archive paths. +- Nested lab workspace: child workspaces, child lockfiles, and package release + profiles were removed. One lock resolved both unique packages; locked offline + release tests and strict Clippy passed the entire archive workspace. +- BogKit root workspace: `cargo test --workspace` passed all 45 unit and + documentation tests against `80fd3c9a023e877fff2e5d127accca386d437af0`. +- Archive boundaries: final checks cover diff whitespace, valid JSON, + changed-path scope, child workspace/locks, generated build/database/binary + output, large files, symlinks, and credential patterns. Every retained change + is under `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-11.md b/developer-simulation/reports/2026-08-11.md new file mode 100644 index 0000000..dc5659a --- /dev/null +++ b/developer-simulation/reports/2026-08-11.md @@ -0,0 +1,273 @@ +# Developer simulation — 2026-08-11 + + + +Two blind developers evaluated substantially different existing-software +problems from separate sanitized copies of current `main`: repairing derived +cold-chain incident state from an authoritative NDJSON archive, and admitting +receiving-dock bookings whose command result and audit must commit together in +PostgreSQL. Both built runnable Fold integrations or boundary reproducers and +reached grounded production no-fit conclusions. A separate skeptical reviewer +confirmed one serious Fold persisted-value error-handling defect and found +three Important scheduling-model defects. The original dock-slot developer +fixed those defects, the same reviewer independently approved the corrections, +and both normalized archives passed fresh workspace checks. No BogKit core or +existing example changed. + +## Trial 1 — Cold-chain excursion state repair + +- Persona: regional food-distributor platform engineer with six years of + production Python and one year of Rust +- Existing system: append-only NDJSON observation/configuration archive with a + disposable derived SQLite state database +- Problem: obtain exact canonical incident state under duplicates, late + uploads, linked corrections, and backdated temperature limits +- Outcome: no fit for the production reducer; the application-specific replay + remains simpler and materially lighter +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold +- Archive: + [`runs/2026-08-11--cold-chain-repair`](../runs/2026-08-11--cold-chain-repair) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml -p cold-chain-repair -- --check` + - `cargo test --manifest-path developer-simulation/Cargo.toml --locked --offline --release -p cold-chain-repair --all-targets` + - `cargo clippy --manifest-path developer-simulation/Cargo.toml --locked --offline --release -p cold-chain-repair --all-targets -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: seven acceptance tests and two CLI/subprocess tests passed. The + 100-seed differential test used fresh Fold databases for chronological + reference, shuffled candidate, and repeated candidate batches. +- Canonical behavior: hand-checked five- and ten-minute boundaries, exact + observation/configuration provenance, correction and backdated-limit + isolation, duplicate idempotency, malformed-batch rejection, and byte-stable + NDJSON-to-JSON repair all passed. +- Process exits: an exit inside an uncommitted Fold transaction, immediately + before snapshot rename, immediately after rename, and restart convergence + all exposed one complete prior-or-next snapshot for the tested boundaries. +- Normalized archive demonstrations: the fixture demo exactly matched the + separate replay oracle, `verify 100` passed, and the process-exit demo passed. +- One-million-observation/400-freezer measurements were independently + reproduced. The reference used 0.716–0.727 seconds inside the reducer and + 511.46–511.51 MB peak RSS; the Fold-backed candidate used 3.230–3.320 seconds + and 1.155–1.156 GB peak RSS. Both met the 60-second target and failed 384 MiB; + the candidate also exceeded the stated 512 MiB server. Fixture generation, + validation, and replay remain in memory, so this is an architecture failure, + not an isolated Fold memory benchmark. + +### Friction trail + +The public Fold examples made a durable keyed record index plausible, while ESE +and ANNy were clearly unrelated to deterministic temperature state. Source +inspection confirmed useful local multi-view atomicity, but no operator for +invalidating and replaying an event-time suffix after an old observation or +configuration changes. The authoritative archive append and canonical snapshot +also remain outside Fold's transaction, so the hardest domain and recovery +logic stayed in the application. + +The first ordinary persisted enum compiled under Fold's public +`Serialize + DeserializeOwned` bounds and committed, then table iteration +panicked with Postcard `WontImplement`. Changing to external enum tagging and +omitting a `None` field with `skip_serializing_if` produced a second read-time +panic, `DeserializeUnexpectedEnd`. The developer retained both minimal +reproducers and used external tagging with the option always serialized in the +working proof. + +The reviewer reproduced both panics and then called Postcard directly without +Fold. Postcard returned the same two expected errors: its non-self-describing +format cannot decode an internally tagged enum or a schema field omitted at +serialization. The consumer-facing Fold defect is therefore the undisclosed +narrower storage contract plus an internal `unwrap` that converts a predictable +type incompatibility into an unrecoverable read-time panic after data commits; +the underlying format limitations are not Postcard correctness bugs. + +### Findings + +1. **Persisted-value incompatibility panics at read time — BogKit/Fold + correctness and error-handling defect, important severity, high + confidence.** Both compact reproducers deterministically exit 101 at + `TableReader::iter`. First document Postcard-compatible Serde shapes at every + persisted-value entry point; then replace serialization/deserialization + `unwrap`s with recoverable typed errors where an API-compatible path exists. + This serious reproducer satisfies the charter's single-defect candidate + exception without implying a new codec subsystem. +2. **Straightforward Fold record index misses the memory ceiling — prototype + performance failure, high confidence.** The reference is already too large, + and the duplicate index makes the candidate substantially worse. Stream the + application-specific reducer rather than adding a BogKit subsystem. +3. **External authority and ordered temporal repair — poor product fit, high + confidence.** Fold does not own the archive/snapshot publication boundary + and does not remove correction/configuration replay. Do not add a temporal + database or suffix-repair primitive from this one workload. +4. **Named pipeline helpers — recurring API friction, high confidence.** The + closure-composed pipeline stayed inline because ordinary helper types were + awkward. This strengthens the existing documentation-first candidate, not + a type-erasure proposal. + +### Decision audit + +The developer chose integer-second timestamps, `(observed_at, observation_id)` +tie ordering, continuous classification across unspecified sampling gaps, +transition time at the first qualifying observation, conflicting-duplicate and +ambiguous-correction rejection, backdated configuration lookup by effective +time, fixed-shape canonical JSON, 4,096-record Fold transactions, and +same-directory synced snapshot rename. A seven-day correction fixture exists, +but no wall-clock cutoff policy was invented. The process-exit suite does not +cover power loss or the boundary after some index chunks commit and before the +full archive is indexed; restart convergence at that exact intermediate point +is reasoned rather than executed. The oracle has a separate replay loop but +shares record types, validation inputs, and fixture structure, so it is an +algorithmically separate oracle rather than a fully independent production +implementation. + +## Trial 2 — Receiving-dock slot admission + +- Persona: warehouse-management backend engineer with eight years of + TypeScript/PostgreSQL experience and three months of Rust +- Existing system: six service replicas using serializable PostgreSQL and + advisory locks, with Redis-based hold expiry outside the commit boundary +- Problem: make hold, confirm, cancel, reschedule, expiry, idempotency, booking, + and audit decisions atomic and deterministic +- Outcome: no fit for the production authority; keep every accepted command + and audit decision in one PostgreSQL transaction +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold in the split-commit reproducer +- Archive: + [`runs/2026-08-11--receiving-slot-admission`](../runs/2026-08-11--receiving-slot-admission) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml -p receiving-slot-admission -- --check` + - `cargo test --manifest-path developer-simulation/Cargo.toml --locked --offline --release -p receiving-slot-admission --all-targets` + - `cargo clippy --manifest-path developer-simulation/Cargo.toml --locked --offline --release -p receiving-slot-admission --all-targets -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: eight tests passed after review, including expiry without an explicit + sweep, reschedule after an unrelated expiry, zero/nine-duration nonmutation, + distinct stale-version and payload-mismatch results, exact retries, + deterministic fixtures, and local collision invariants. +- Domain-model smoke: a deterministic 10,000-command fixture plus 100 generated + seeds passed; four threads over 30 runs preserved invariants behind one local + mutex. These are explicitly not PostgreSQL or six-process evidence. +- Split-commit reproducer: real child exits immediately before and after a + mocked atomic authority commit. An exit after the authority commit and before + the real Fold mirror write leaves booking/audit present with no Fold row. + Retry can repair the mirror but cannot make the original commits atomic. +- Normalized archive demo: all model and split-commit checks passed. The local + repeat-heavy 100,000-command run measured about 2.69–2.71 million commands/s + and 0.000459–0.000500 ms p95. These numbers are invalid for the PostgreSQL + acceptance gate and are retained only as a runnable smoke check. +- PostgreSQL gate: not run. `psql`, `postgres`, and `pg_isready` were absent; + port 5432 was closed; no connection setting existed; and no service was + installed or started. Serializable locking, database timestamps, four real + clients, six replicas, schema size, and database latency remain unverified. + +### Friction trail + +The public examples showed that Fold atomically maintains views inside an +embedded store, but did not state the external transaction or writer boundary. +Minimal source inspection found a concrete Fjall `SingleWriterTxDatabase`: +`Stream::wtx` creates and commits its own transaction and exposes no +PostgreSQL/caller-owned transaction, prepare/commit, or storage-adapter hook. +ESE and ANNy were irrelevant to exact scheduling. A downstream outbox-fed Fold +projection remained possible, but no measured read need justified operating a +second persisted system. + +The first model also demonstrated why skeptical review is required. Expired +holds continued consuming capacity unless an explicit sweep ran, zero-duration +reschedules were accepted, and the exact pedantic lint gate failed with 19 +diagnostics. The original developer moved logical-time expiry into every new +non-replayed authority command, added range validation/nonmutation, replaced +lossy conversions, and separated a genuine stale-version assertion from +payload mismatch. Eight tests, strict lint, the release demo, and the real +split-commit boundary then passed independently. + +### Findings + +1. **Expired-capacity and invalid-reschedule bugs, fixed — prototype + correctness defects, important severity, high confidence.** Logical + `expires_at <= command_now` processing and duration `1..=8` validation now + occur before mutation. Permanent regressions cover both fixes; no BogKit + core change was involved. +2. **External transaction/writer boundary — documentation gap, important + severity, high confidence.** State concisely that Fold transactions are + scoped to the embedded Fjall store and do not join an external database + transaction, and name the intended writer topology. Do not add a PostgreSQL + adapter based on this no-fit workload. +3. **Component-selective setup and nameable pipelines — recurring API + friction, high confidence.** Only Fold was relevant, yet the starter bundles + unrelated search components, and closure-bearing pipeline types remain hard + to name for ordinary helpers. Keep both improvements narrow and + documentation/scaffolding-first. +4. **PostgreSQL command authority — poor product fit, high confidence.** The + existing database already provides the only commit boundary that can satisfy + the requirement. No BogKit dependency is the lean result. + +### Decision audit + +The model chose operation-scoped idempotency keys, stable payload hashes, +confirmed-booking exclusivity despite pallet spare capacity, ascending door +selection, time-conflict precedence, expiry at equality, explicit and implicit +expiry, injected logical command time, one decision row per stored command, +and eventual mirror repair only for demonstration. Payload-mismatch attempts +remain visible but do not append a second durable decision; carrier priority, +database schema/indexing, HTTP serialization, migrations, connection pooling, +real lock ordering, and production retry behavior remain unresolved. The +100-seed comparison repeats the same model, four workers serialize through one +mutex, and the atomic file is only a benign authority model. None is presented +as database evidence. + +## Skeptical review + +- Trial 1: `APPROVED_FOR_ARCHIVE`. Nine tests, formatting, strict pedantic + Clippy, demo, 100 seeds, process-exit boundaries, independent one-million + measurements, and both Fold/Postcard reproducers passed. Two Minor evidence + limits remain disclosed. +- Trial 2 initial verdict: `REJECTED_UNTIL_FIXED`. Three Important items were + the expired-hold capacity bug, invalid reschedule duration, and failed strict + lint gate. One Minor evidence-name issue was also corrected. +- Trial 2 final verdict: `APPROVED_FOR_ARCHIVE_AFTER_FIXES`. Eight tests, + formatting, strict pedantic Clippy, logical-time source inspection, release + demo, split-commit boundary, documentation, and cleanliness all passed the + same reviewer's scoped rerun. The PostgreSQL evidence limit remains. +- Consolidated review count: 0 blocker/critical; 3 Important fixed and 0 + remaining; 4 Minor notes, of which one was fixed and three remain as explicit + evidence limits. + +## Cross-run synthesis + +- New confirmed candidate: Fold's persisted-value codec panic meets the + one-serious-reproducer threshold. Keep it separate from the caught-`wtx` + writer-poisoning candidate because the roots and corrections differ. +- Storage, transaction, and concurrency boundaries: 24 independent trials. + Today's runs add an authoritative external archive/snapshot boundary and an + external PostgreSQL/multi-process boundary. +- Component-selective setup: 12 independent sources. The dock-slot developer + again rejected unrelated ESE/ANNy setup for a Fold-only evaluation. +- Nameable pipeline/readers: 7 independent sources, now strengthened by both + developers. Preserve the documentation-first scope. +- Do not promote event-time suffix replay, a PostgreSQL adapter, distributed + writer coordination, or a general constraint solver. Each is either a + one-scenario missing capability or an explicit product-fit boundary. +- Outcome totals: 30 trials — 27 `no_fit` and 3 + `local_proof_only_no_production_fit`. + +## Validation + +- New packages: all 17 focused tests passed; formatting and strict release + Clippy passed with warnings, `clippy::all`, and `clippy::pedantic` denied. +- Runnable evidence: cold-chain fixture/reference, 100-seed differential, + process-exit recovery, both expected Fold panic reproducers, dock-slot domain + model, local collision smoke, and real Fold split-commit boundary ran from + normalized archive paths. +- Nested lab workspace: child workspaces, child lockfiles, and package release + profiles were removed. One lock resolves both unique packages. Locked offline + release tests passed the complete nested workspace, and workspace-wide Clippy + passed with warnings denied. The unchanged ESE model/tokenizer were supplied + only through an external build cache; no model or generated data was archived. +- BogKit root workspace: `cargo test --workspace --locked --offline` passed all + 45 unit and documentation tests against + `80fd3c9a023e877fff2e5d127accca386d437af0`. +- Archive boundaries: final checks cover diff whitespace, valid JSON, + changed-path scope, child workspace/locks/profiles, generated build/database/ + binary output, large files, symlinks, and credential patterns. Every retained + change is under `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-12.md b/developer-simulation/reports/2026-08-12.md new file mode 100644 index 0000000..4a2d77c --- /dev/null +++ b/developer-simulation/reports/2026-08-12.md @@ -0,0 +1,224 @@ +# Developer simulation — 2026-08-12 + + + +Two blind developers evaluated substantially different existing-software +problems from separate sanitized copies of current `main`: an offline +authoritative-DNS change-admission gate and a hard-real-time MIDI event +scheduler. Both independently rejected Fold, ESE, and ANNy, built compact +dependency-free Rust evidence artifacts, and retained the existing production +baseline. A separate skeptical reviewer rejected both first handoffs, found one +Critical and eight Important prototype defects, and approved both only after +the original developers fixed every serious issue and the same reviewer +reproduced the corrections. No BogKit core or existing example changed. + +## Trial 1 — Authoritative DNS change-admission gate + +- Persona: hosting-platform reliability engineer with seven years of Go/shell + and four months of Rust +- Existing system: `named-checkzone` remains the syntax authority while humans + inspect diffs before deployment +- Problem: produce a deterministic, fail-closed semantic change report over old + and proposed BIND master-file snapshots +- Outcome: no fit; retain the existing authority and treat the standalone gate + only as an advisory prototype over immutable input snapshots +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-12--dns-change-gate`](../runs/2026-08-12--dns-change-gate) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml -p dns-change-gate -- --check` + - `cargo test --manifest-path developer-simulation/Cargo.toml --locked --offline --release -p dns-change-gate --all-targets` + - `cargo clippy --manifest-path developer-simulation/Cargo.toml --locked --offline --release -p dns-change-gate --all-targets -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: 22 black-box tests and one fixture-generator integration passed in the + repaired trial. They cover the declared parser subset, deterministic order, + semantic rules, supplied baseline results, bounded expansion, static + containment, input/output identity, and complete report publication. +- Demonstration: the release CLI produced the checked-in 853-byte oracle + exactly. Maximum-`u32` and stepped `$GENERATE` ranges failed promptly without + replacing the prior report; decoded 63-octet escaped labels canonicalized + exactly while 64-octet labels failed closed. +- Local scale observation: a synthetic 10,000-zone, 2,000,000-record-per- + snapshot corpus ran three times in 3.38–3.45 seconds at 18.63–18.79 MB peak + RSS after a 4.20-second warm-up. This was an Apple M4 Pro macOS host, not the + declared four-core Linux/256 MiB environment, and the regular corpus did not + contain the requested exhaustive semantic oracle set. + +### Friction trail + +The public material made the component mismatch clear: embeddings and +approximate search cannot make exact DNS decisions, while a persistent Fold +store adds state and failure boundaries without parsing master files or +improving a bounded comparison. A standard-library sorted representation was +smaller and easier to audit. + +Test-first work caught omitted `$TTL` inheritance and ambiguous per-snapshot +record-cap accounting. Skeptical review then found the more valuable issues: +unchecked expansion cardinality, wire length measured from rendered escapes, +overstated concurrent-filesystem containment, non-exclusive temporary report +creation, and missing input/output identity protection. The repaired prototype +uses checked wide cardinality before iteration, decoded-octet length accounting, +exclusive owned temporary files, alias rejection, and an explicitly narrowed +immutable-snapshot contract. + +### Findings + +1. **No component improves the bounded exact gate — poor product fit, high + confidence.** Fold adds durable storage that the one-shot job does not need; + ESE and ANNy are categorically unrelated. Keep `named-checkzone` authoritative + and do not add a BogKit subsystem for this workload. +2. **Operational-boundary guidance remains hard to discover — documentation + gap, high confidence.** The README should make no-component outcomes and + bounded stateless exact tools explicit in the existing capability matrix + candidate. This strengthens recurring evidence; it does not justify an + in-memory Fold mode. +3. **First-handoff parser/publication defects, fixed — prototype correctness, + high confidence.** One Critical range-overflow issue and four Important + containment, publication, alias, and decoded-length issues now have permanent + regressions. They are not BogKit defects. +4. **Production evidence remains incomplete — validation limit, high + confidence.** No real `named-checkzone` comparison, exhaustive 1,000/60/40 + oracle tables, concurrent-mutation containment, or target Linux benchmark + exists. The archive preserves `Partial` and `Representative pass` labels. + +### Decision audit + +The developer chose a declared fail-closed parser subset, per-snapshot expansion +caps, active `$TTL` inheritance into includes, scoped include-local state, +decoded-octet name limits, RFC 1982 serial arithmetic, review rather than block +for delegation changes, literal `PASS` baseline codes, deterministic JSON with +no host fields, and complete semantic-block publication. The input trees and +policy must remain immutable for the entire run because pathname-based reads do +not prove safety under concurrent replacement. Application fault points do not +prove kernel, filesystem, power-loss, or production durability. + +## Trial 2 — Hard-real-time MIDI event scheduler + +- Persona: ten-year production C++ audio developer with Rust Book and two hobby + crates but no shipped Rust callback +- Existing system: production C++ scheduler plus a slower rational test oracle +- Problem: make exact timing, transport cleanup, overload, duplicate handling, + and plan handoff executable without weakening the audio callback contract +- Outcome: no fit for BogKit and no production Rust replacement; keep C++ until + lock, syscall, FFI, and genuinely concurrent reclamation gates are proved +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-12--midi-scheduler-rt-model`](../runs/2026-08-12--midi-scheduler-rt-model) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml -p midi-scheduler-rt-model -- --check` + - `cargo test --manifest-path developer-simulation/Cargo.toml --locked --offline --release -p midi-scheduler-rt-model --all-targets` + - `cargo clippy --manifest-path developer-simulation/Cargo.toml --locked --offline --release -p midi-scheduler-rt-model --all-targets -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: all 29 passed after repair — 14 callback, six validation, four modeled + publication, two oracle/determinism, two real-time instrumentation, and one + representative-shape test. +- Correctness: 100 fixed seeds agreed with a separately written exact rational + oracle. Focused cases cover half-open frames, collapsed events, strict + successful-token monotonicity, transport resets, identity-checked notes, + retryable failed callbacks, 4,096-event saturation, and ordinary/mixed + termination overload with zero stuck active notes. +- Scale observations: the release representative test prepared 180,000 events + and 20,000 tempo nodes for 45 minutes with 250 modeled replacements. An + uncontrolled, unpinned 10,000,000-call macOS run observed p99 125 ns and + maximum 49.791 microseconds. It is not a production comparison or portable + guarantee. + +### Friction trail + +The public component contracts were straightforward no-fits: Fold is persistent +transactional state, ESE allocates text embeddings, and ANNy performs allocating +approximate search. None belongs in an exact fixed-capacity callback. The useful +work was instead making the existing callback contract executable in a small +standard-library model. + +Skeptical review found four Important state defects missed by the initial 23 +tests: ordinary termination overload could leave notes active, termination +metadata did not match the onset, a non-consecutive old token could re-emit, and +a failed discontinuity consumed its retry token. The repaired model consolidates +excess terminations into per-channel resets, validates full note identity, +requires strictly increasing successful tokens, and commits a token only after +successful scheduling. Six new regressions brought the suite to 29 tests. + +### Findings + +1. **BogKit is outside the callback boundary — poor product fit, high + confidence.** Do not change Fold, ESE, or ANNy for this use case. A future + editor-side history evaluation must remain separate from the callback. +2. **First-handoff callback-state defects, fixed — prototype correctness, high + confidence.** Four Important issues now have permanent public-interface + regressions. They are evidence for the domain contract, not BogKit defects. +3. **Hard-real-time proof is absent — missing production evidence, high + confidence.** The fake handoff is single-threaded; lock attempts, forbidden + system calls, FFI panic behavior, and real concurrent reclamation were not + instrumented. Keep the C++ baseline. +4. **Local latency is encouraging but incomplete — performance evidence, medium + confidence.** The run had no pinned core, controlled host, repeated series, + CPU identity, or C++ comparison; the binary digest is rebuild-dependent. + +### Decision audit + +The model uses exact checked rational accumulation and containing-frame floor, +frame/priority/stable ordering, full `(instance_id, channel, key)` identity, +strictly increasing successful callback tokens, post-success token commit, +per-channel `AllNotesOff` fallback, suppression of ordinary note lifecycle +during fallback, fixed-capacity active state, and sticky overload reporting. +The callback library contains no unsafe block; only the test allocator uses +unsafe forwarding and counts allocation, zeroed allocation, and reallocation +for 100,000 calls, not deallocation or the 10-million-call timing run. The +two-slot handoff models named schedules with exclusive access and is not a +lock-free primitive or memory-model proof. + +## Skeptical review + +- Initial verdicts: DNS `REJECTED_PENDING_FIX` with one Critical, four + Important, and two Minor; MIDI `REJECTED_PENDING_FIX` with four Important and + three Minor. +- Final scoped verdicts: both `APPROVED_FOR_ARCHIVE_AFTER_FIXES`. One Critical + and eight Important findings are fixed; zero Critical or Important findings + remain. Five Minor evidence limits remain explicitly disclosed. +- Fresh reviewer checks passed formatting, strict pedantic Clippy, 22 DNS + black-box plus one generator test in debug and release, the byte-exact DNS + oracle and focused boundary cases, all 29 MIDI tests, the release + representative shape, and five independent corrected callback checks. +- No BogKit correctness defect or new candidate improvement is justified. + +## Cross-run synthesis + +- Storage and concurrency boundaries: 26 independent trials. Today's runs add + an immutable filesystem/report-lifecycle workload and a hard-real-time + callback/publication boundary. +- Component-selective setup: 14 independent sources. Both developers correctly + avoided unrelated BogKit and third-party dependencies. Keep the candidate + documentation/scaffolding-first rather than adding a generic abstraction. +- Nameable pipeline/readers: unchanged at seven; neither trial exercised it. +- Confirmed defect candidates remain unchanged: caught Fold write-transaction + panic poisoning and persisted-value codec panic. +- Outcome totals: 32 trials — 29 `no_fit` and three + `local_proof_only_no_production_fit`. +- Do not promote a DNS parser, in-memory Fold mode, descriptor traversal layer, + MIDI scheduler, callback publication primitive, or general real-time API. + +## Validation + +- New packages: 52 focused tests pass after archive normalization; formatting + and strict release Clippy pass with warnings, `clippy::all`, and + `clippy::pedantic` denied. +- Runnable evidence: the DNS demo matches its checked-in oracle byte-for-byte; + the MIDI release representative shape and bounded callback model pass from + normalized archive paths. +- Nested lab workspace: one workspace and lockfile resolve both uniquely named + packages; no child workspace, lockfile, or package-local release profile + remains. +- BogKit root workspace: `cargo test --workspace --locked --offline` passes all + 45 unit and documentation tests against + `80fd3c9a023e877fff2e5d127accca386d437af0`. +- Archive boundaries: final checks cover diff whitespace, valid JSON, + changed-path scope, generated build/database/binary output, large files, + symlinks, and credential patterns. Every retained change is under + `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-13.md b/developer-simulation/reports/2026-08-13.md new file mode 100644 index 0000000..b3dd999 --- /dev/null +++ b/developer-simulation/reports/2026-08-13.md @@ -0,0 +1,237 @@ +# Developer simulation — 2026-08-13 + + + +Two blind developers evaluated substantially different existing-software +problems from separate sanitized copies of current `main`: advisory return and +refund reconciliation across carrier, warehouse, and payment evidence, and +municipal water-meter billing repair from cumulative-reading exports. Both +built dependency-light exact batch tools, rejected Fold, ESE, and ANNy, and +retained the existing external database and approval flows. A separate +skeptical reviewer rejected both initial handoffs, found one Critical and five +Important prototype or evidence defects, and approved the archives only after +two fix rounds by the original developers. No BogKit core or existing example +changed. + +## Trial 1 — Multi-carrier return refund reconciler + +- Persona: commerce-platform backend developer with seven years of + TypeScript/PostgreSQL and four months of Rust +- Existing system: PostgreSQL remains authoritative; a nightly Python/CSV job + creates an operator review sheet and a separate approved service executes + refunds +- Problem: derive an exact, deterministic advisory refund plan from split + parcels, warehouse corrections, substituted items, and ambiguous payment + results without issuing a second refund +- Outcome: no fit; retain the direct read-only snapshot/reference approach and + keep payment execution outside the prototype +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-13--multi-carrier-return-refund-reconciler`](../runs/2026-08-13--multi-carrier-return-refund-reconciler) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml -p return-reconciler-trial1 -- --check` + - `cargo test --manifest-path developer-simulation/Cargo.toml --locked --offline --release -p return-reconciler-trial1 --all-targets --all-features` + - `cargo clippy --manifest-path developer-simulation/Cargo.toml --locked --offline --release -p return-reconciler-trial1 --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: 20 release tests passed after repair. Permanent regressions cover + exact/conflicting retries, correction order, split parcels, substituted and + excess units, payment states, cent and quantity caps, checked report totals, + invalid payment ownership, verifier provenance, input/output aliases, normal + publication failures, and abrupt child exits. +- Representative shape: the generator produced exactly 25,000 returns, 40,000 + parcels, 70,000 authorized lines, 250,000 events, and 500 labeled adversarial + returns. Candidate and separately structured reference agreed, and ten input + shuffles produced byte-identical reports. The final trial run observed about + 273 ms candidate work, 303 ms reference work, and 5.844 seconds for the ten + shuffles. +- Memory evidence: three reviewer child-process high-water measurements were + 506,707,968–523,190,272 bytes (483.2–499.0 MiB), leaving only 13–29 MiB below + the 512 MiB target. This is a narrow host-specific soft pass, not a portable + resource guarantee. +- Baseline comparison: the production Python/SQL implementation and real + exports were unavailable, so no runtime or operational superiority was + measured. The prototype adds executable audit rules but does not displace + the authoritative baseline. + +### Friction trail + +The public examples made ESE and ANNy direct no-fits for exact IDs, units, and +cents. Fold offered durable transactions and retractions, but the input is an +immutable PostgreSQL export and the output an advisory file: persisting another +copy would not remove reconciliation, quarantine, allocation, provenance, +reference verification, or external publication logic. + +Test-first work found a relative-output false failure after a successful rename +and an underpowered provenance verifier. Skeptical review then found the +financially important gaps: return caps could report a full unit with only +partial-unit cents; report-wide `u64` totals silently wrapped; and a pending +payment result for a nonexistent or wrong-return line was ignored while a +refund remained proposed. The repaired candidate, reference, and verifier now +fund whole units only, use checked report totals, and validate every raw payment +line/return identity before derivation and publication. + +### Findings + +1. **BogKit does not improve this authoritative-snapshot boundary — poor + product fit, high confidence.** Fold adds a second durable store without + sharing PostgreSQL's authority or removing exact domain rules; ESE/ANNy are + unsafe or irrelevant for exact accounting. Retain no component. +2. **External authority and report lifecycle remain onboarding boundaries — + documentation gap, high confidence.** Add this source to the existing public + capability/operational-boundary matrix; do not infer a new report-publisher + or refund subsystem. +3. **Initial financial-accounting defects, fixed — prototype correctness, high + confidence.** One Critical overflow and two Important unit/payment issues + now have permanent fail-before-publication regressions. They are not BogKit + defects. +4. **Resource and production evidence are narrow — performance evidence limit, + high confidence.** The synthetic local run has little memory headroom and + no real Python/SQL comparison, Linux qualification, operator study, or + production-policy approval. + +### Decision audit + +The prototype uses checked integer cents, deterministic whole-unit funding, +stable remainder-cent allocation, line-ID order for same-SKU authorizations, +explicit conflict quarantine, source/ingestion/event ordering for named +corrections, success-dominant prior payment results, a conservative pending +block, and atomic verified sibling-file publication. Money-field semantics, +remainder policy, multiple-correction policy, same-SKU allocation, return-cap +allocation, carrier-status authority, and operator workflow remain product +decisions rather than demonstrated production rules. + +## Trial 2 — Municipal water-meter billing repair + +- Persona: municipal-utility data engineer with nine years of SQL/Python and + one year of Rust +- Existing system: an authoritative vendor billing database plus understood + SQL views, Python repair scripts, and a separate adjustment-approval import +- Problem: deterministically reconstruct cumulative consumption across meter + rollover, replacement, estimates, corrections, and already billed intervals +- Outcome: no fit; retain the auditor-understood external authority and use the + prototype only as an exact policy/evidence model +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-13--municipal-water-meter-billing-repair`](../runs/2026-08-13--municipal-water-meter-billing-repair) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml -p water-repair -- --check` + - `cargo test --manifest-path developer-simulation/Cargo.toml --locked --offline --release -p water-repair --all-targets` + - `cargo clippy --manifest-path developer-simulation/Cargo.toml --locked --offline --release -p water-repair --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: all 19 passed after repair. They cover rollover/regression, + replacement boundaries, supersession, duplicates/conflicts, exact prior + adjustments, ambiguity isolation, ordering and batch determinism, path + identity, globally unique adjustment IDs, exact retries, and four real-child + `SIGABRT` publication cases. +- Correctness: the disclosed demo produced five repairs and one review and + matched its hand-written canonical oracle byte-for-byte. A separately + structured chronological reference agreed on 100 deterministic seeds and ten + permutations at batch sizes 1, 7, and 64. +- Process exits: real children terminated before staging and after a complete + synced temporary file immediately before rename. Existing finals remained + byte-identical, absent finals stayed absent, and restart removed the orphan + stage before publishing one complete report. +- Requested-count observation: a synthetic pre-sorted workload contained + 100,000 service points, 120,000 meters, 2,000,000 readings, and 200,000 billed + intervals and completed in 0.159 seconds at 139.8 MiB peak RSS. It has only two + intervals per point, no corrections or prior adjustments, and zero repairs or + reviews. This is explicitly a partial best-case traversal/publication check, + not representative repair performance. + +### Friction trail + +Fold was the only plausible component because keyed updates and retractions +match corrected readings. Narrow source inspection confirmed those primitives, +but every consequential rule—installation validity, register rollover, +supersession, causal interval repair, provenance, per-service refusal, +canonical output, and external publication—remained application code. Durable +embedded state added lifecycle cost without measured benefit. ESE and ANNy were +unrelated to exact integer/time reconstruction. + +The initial prototype passed ordinary tests but modeled publication failures as +returned errors rather than process exits and overstated a trivial large-shape +benchmark. Review also found candidate/reference disagreement when one +adjustment ID was reused across billed intervals. The repair adds real abrupt +child exits, labels the scale result partial/best-case, and establishes one +global snapshot adjustment-ID contract: identical rows are idempotent retries; +any differing reuse fails deterministically before derivation or publication. + +### Findings + +1. **Fold does not remove temporal or external-publication ownership — poor + product fit, high confidence.** Keep the direct chronological reducer and + external billing authority; do not add durable state for this batch job. +2. **Operational-boundary guidance is recurring — documentation gap, high + confidence.** This independently strengthens the existing capability matrix + for immutable offline transformations and caller-owned publication. +3. **Initial lifecycle and identity defects, fixed — prototype correctness and + evidence defects, high confidence.** Three Important findings now have + permanent subprocess, identity, retry, and order regressions. None is a + BogKit defect. +4. **Scale and policy evidence remain partial — performance/validation limit, + high confidence.** The requested-count run is deliberately best-case; no + production export, regulatory review, approved rollover policy, baseline + timing, concurrent writer, or power-loss campaign exists. + +### Decision audit + +The model uses exact integer volumes, canonical source ordering, a conservative +top-quarter-to-bottom-quarter rollover rule, fail-closed installation gaps and +ambiguous equal timestamps, explicit old/new meter boundary readings, named +correction targets, two adjacent causally affected intervals, global reading +and adjustment IDs with exact-retry collapse, prior adjustments added to old +usage, per-service ambiguity isolation, and one-writer atomic report +publication. Rollover, replacement-boundary, ambiguity, and adjustment policies +require utility approval. Stale-stage cleanup is not a multi-writer protocol. + +## Skeptical review + +- Trial 1 initial verdict: `REJECTED_UNTIL_FIXED` with one Critical, two + Important, and two Minor findings. Trial 2 initial verdict: + `REJECTED_UNTIL_FIXED` with three Important and two Minor findings. +- Fix round 1 closed whole-unit funding, abrupt-exit evidence, and performance + wording, but the reviewer found overflow, invalid payment ownership, and + adjustment-ID divergence through independent harnesses. +- Fix round 2 independently reproduced the repaired counterexamples. Final + verdicts: both `APPROVED_FOR_ARCHIVE_AFTER_FIXES`, with zero Critical or + Important and two disclosed Minor evidence limits per trial. +- No BogKit correctness defect or new candidate improvement is justified. + +## Cross-run synthesis + +- Storage and concurrency boundaries: 28 independent trials. Both runs add an + immutable external-database export and caller-owned canonical-report + publication boundary. +- Component-selective setup remains 14; neither trial substantially integrated + a Fold pipeline, so today's observations do not justify an increment. +- Nameable pipeline/readers remains seven. +- Confirmed BogKit defects remain unchanged: caught Fold write-transaction + panic poisoning and persisted-value codec panic. +- Outcome totals: 34 trials — 31 `no_fit` and three + `local_proof_only_no_production_fit`. +- Do not promote a refund reconciler, water-billing repair subsystem, generic + report publisher, in-memory Fold mode, or domain-specific temporal API. + +## Validation + +- New packages: 39 focused release tests pass after archive normalization; + formatting and strict pedantic Clippy pass. +- Runnable evidence: the return demo/verifier, exact representative/reference + and ten-shuffle gate, water disclosed byte oracle, 100 seeds, ten-by-three + determinism, real-child publication suite, and focused overflow/payment/ + adjustment-identity regressions pass from normalized archive paths. +- Nested lab workspace: one workspace and lockfile resolve both unique new + packages; no child workspace, lockfile, or package-local profile remains. +- BogKit root workspace: `cargo test --workspace --locked --offline` passes all + 45 unit and documentation tests against + `80fd3c9a023e877fff2e5d127accca386d437af0`. +- Archive boundaries: final checks cover valid JSON, diff whitespace, + changed-path scope, secrets, large/binary files, symlinks, child workspaces, + locks/profiles, and generated build/database/runtime residue. Every retained + change is under `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-14.md b/developer-simulation/reports/2026-08-14.md new file mode 100644 index 0000000..5ad623b --- /dev/null +++ b/developer-simulation/reports/2026-08-14.md @@ -0,0 +1,253 @@ +# Developer simulation — 2026-08-14 + + + +Two blind developers worked from separate sanitized copies of current `main`. +One evaluated crash-safe branching undo history for a vector editor; the other +evaluated an epoch-safe local mailbox mirror beside an authoritative server. +Both started from their supplied baselines before inspecting BogKit. A separate +skeptical reviewer rejected both initial handoffs, independently reproduced one +Critical and six Important correctness or evidence problems, and approved the +archives only after one focused repair round by the original developers. + +The undo trial retained Fold as a narrow authoritative whole-state transaction +and read-view proof, but its measured layout failed the latency, reopen, and +storage comparison gates. The mailbox trial retained SQLite and used Fold only +for a decisive transaction reproducer. It independently confirmed the already +tracked caught-panic writer-poisoning defect on current `main`; it did not +justify a new API or subsystem. BogKit core and existing examples remain +unchanged. + +## Trial 1 — Crash-safe branching undo history + +- Persona: vector-editor document-core maintainer with eight years of Swift and + C++ and six months of Rust +- Existing system: canonical JSON documents, an in-memory undo stack, and a + full-document autosave every 30 seconds +- Problem: persist grouped undo/redo, branch invalidation, stable retry + outcomes, compaction, and acknowledged crash recovery +- Outcome: `local_proof_only_no_production_fit` +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold +- Archive: + [`runs/2026-08-14--crash-safe-undo-history`](../runs/2026-08-14--crash-safe-undo-history) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml --package undo-history-lab -- --check` + - `cargo test --manifest-path developer-simulation/Cargo.toml --offline --locked --package undo-history-lab --all-targets` + - `cargo clippy --manifest-path developer-simulation/Cargo.toml --offline --locked --package undo-history-lab --all-targets -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: all 24 passed after repair. Permanent regressions cover physical + final-tail repair followed by a later commit and second reopen, middle + corruption, object and point overflow without mutation, exact write-free + retries, unique-commit snapshot cadence, exact command identity across + restart and compaction, grouped undo/redo and branching, deterministic + fixtures, and real child exit after the durability point. +- Bounded comparator: seed 9001 used 32 initial objects and 1,000 actions. Fold + candidate, JSONL-plus-snapshot baseline, and the in-memory model agreed on + document digest `b426134b815e9add`, head 843, undo 784, redo 0, and transcript + digest `f6251bb4865c3c56`. +- Bounded performance: candidate p95 edit-submission latency was 17.050 ms + versus 5.395 ms for the baseline. Reopen was 19.528 ms versus 23.743 ms, + only 1.22x faster. Pre-compaction storage was 37,533,711 bytes versus + 535,051; post-compaction storage was 2,111,098 versus 2,108,204 bytes. + This one-host run fails the requested material-improvement and non-regression + gates. +- Scale boundary: the 60,000-object/250,000-action campaign, 30 seeds, 400 + process exits, four concurrent inspectors, 20,000-group reversible window, + peak resident memory, and every crash point remain unproved. + +### Friction trail + +The public persistence examples showed Fold transactions and consistent reads, +but deleted their stores at startup and did not demonstrate reopen, +acknowledgement, checkpoint, or recovery diagnostics. Fold was therefore tested +only after the JSONL baseline was defined. ESE and ANNy were direct no-fits for +exact structured history. + +The smallest Fold candidate stored one authoritative complete history value. +That made Fold central rather than decorative, while leaving command +validation, inverse construction, branching, exact request identity, +canonical JSON, retention, and directory-rebuild compaction in application +code. Test-first work found ordinary prototype issues before skeptical review. +The reviewer then found that a recognized bad final JSONL tail remained on +disk, extreme translations could panic after mutation, retries still wrote and +advanced snapshot cadence, and a 64-bit digest stood in for exact command +equality. The repaired implementation truncates and syncs recognized final-tail +damage, prevalidates all arithmetic, makes exact retries write-free, and stores +canonical command bytes for authoritative equality. + +### Findings + +1. **Fold supplies bounded atomic whole-state persistence and snapshot reads — + partial local utility, high confidence.** The proof is real but narrow; it + does not make Fold an undo engine. +2. **The tested whole-state layout is not production-competitive — performance + and storage problem, high confidence.** It missed the latency and comparison + gates before the requested scale. Do not infer a new API from this layout. +3. **Reopen and durability guidance remains hard to discover — documentation + gap, high confidence.** A small public reopen/checkpoint example is the + smallest improvement; documentation before API work. +4. **The review findings were prototype defects, now fixed — correctness, + high confidence.** They are not BogKit defects. +5. **Production evidence is incomplete — evidence limit, high confidence.** + Memory, full scale, reader schedules, and the broad crash campaign are not + qualified. + +### Decision audit + +The candidate deliberately used a whole-state value as the smallest decisive +Fold adapter, checkpoints before an accepted unique edit returns, keeps exact +canonical command bytes for request identity, preserves complete grouped +undo/redo semantics, and rebuilds a compacted directory. A normalized key-per- +document/history/outcome layout remains untested. Power-loss behavior, migration +from the prototype's earlier dedup encoding, an independent semantic oracle, +and production editor integration remain uncertain. + +## Trial 2 — Epoch-safe mailbox mirror repair + +- Persona: desktop mail-sync maintainer with twelve years of Kotlin and SQLite + and three months of Rust +- Existing system: an authoritative server plus a local SQLite mirror with + explicit accepted-batch transactions and staged epoch replacement +- Problem: exact replay, UID-validity isolation, stable mailbox lifecycle, + atomic old-or-new reader visibility, and deterministic recovery +- Outcome: `no_fit` +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold only in a decisive reproducer +- Archive: + [`runs/2026-08-14--epoch-safe-mailbox-mirror`](../runs/2026-08-14--epoch-safe-mailbox-mirror) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml --package mailbox-mirror-lab -- --check` + - `cargo test --manifest-path developer-simulation/Cargo.toml --offline --locked --package mailbox-mirror-lab --all-targets` + - `cargo clippy --manifest-path developer-simulation/Cargo.toml --offline --locked --package mailbox-mirror-lab --all-targets --no-deps -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: all 22 ordinary tests passed after repair; the explicit local + measurement test passed separately in release mode. Coverage includes exact + replay, staged epoch replacement, deliberate UID reuse, flag replacement, + expunge, rename/delete/recreate, cursor refusal, truncation, chunking, + concurrent reader snapshots, integer bounds, staged-row cleanup, and Fold + transaction characterization. +- Full-shape single seed: 80 mailboxes, 300,000 live messages, 600,000 decoded + responses, 30,000 duplicate responses, 12 epoch changes, 20 renames, 10 + delete/recreate lifecycles, one mid-batch disconnect, and five checkpoints. + SQLite and the pure reference matched exactly in 49.101 seconds. Replaying the + complete transcript admitted zero new batches and preserved manifest hash + `ca7ad005908e1699ae14787552eb4880a2fa1c551845cffbcb528b6f30349909`. +- Bounded measurement: the repaired trial snapshot applied 10,000 messages in + 821.745 ms at 12,193.6 responses per second; count p95 was 3.593 ms, reopen + plus checkpoint was 2.347 ms, and SQLite plus sidecars occupied 2,867,200 + bytes. A fresh normalized-archive rerun observed 913.095 ms, 10,973.7 + responses per second, 3.337 ms count p95, 2.483 ms reopen, and the same disk + size, so these are host-local observations rather than stable performance + guarantees. +- Evidence boundary: this is one of 30 requested full seeds. The 400 abrupt + exits, peak RSS, write amplification, 64 full-transcript cuts, and safe Fold + candidate comparison remain unproved. + +### Friction trail + +The SQLite baseline was implemented and measured before minimal Fold source +inspection. Fold's public closure commits whenever it returns normally, +including a returned `Result::Err`; that documented contract means fallible +external batches need prevalidation or an overlay. The trial correctly treats +this as onboarding friction, not an `Err` rollback defect. + +The decisive same-stream reproducer separates that contract from an existing +correctness defect: a panicking transaction's row is absent after rollback, +but after the panic is caught, the next write panics with `poisoned tx lock`. +This independently confirms the defect first archived on August 10. A safe +mailbox adapter would still require custom admission, staging inventories, +cursor validation, replay state, publication state, and multi-key read +projection, so the prototype retained SQLite. + +Review also found that the prototype admitted Rust `u64` values SQLite could +coerce to floating point and left staged messages after mailbox deletion. Both +models now reject values outside `0..=i64::MAX` before admission or mutation, +and the admitted delete transaction removes staged rows. Reopen, repeated +lifecycle, incomplete-stage, and rejected-rescan regressions pass. + +### Findings + +1. **SQLite remains the better fit — poor product fit, high confidence.** No + Fold adapter crossed the correctness threshold or demonstrated the required + maintenance or performance improvement. +2. **Caught Fold write panic poisons later writes — correctness defect, high + confidence.** This is the second independent source for the already-tracked + defect. The narrow existing candidate remains: drop the underlying writer + before resuming the panic and add Stream and KeyedStream recovery tests. +3. **Normal `Err` commits are documented but easy to misuse — API/documentation + friction, medium confidence.** Add a prevalidation/overlay example and clear + normal-return wording; do not infer a new transaction API. +4. **Integer-domain and staged-delete gaps were prototype defects, now fixed — + correctness, high confidence.** They do not justify BogKit changes. +5. **The full evidence campaign is incomplete — evidence limit, high + confidence.** Keep the synthetic one-host result local. + +### Decision audit + +The prototype buffers decoded batches, admits one exact canonical batch ID in +the same SQLite transaction as its effects, validates the signed SQLite domain +before admission, stages complete epochs, publishes them atomically, and +composes each checkpoint in one SQLite snapshot. It deliberately uses the host +`sqlite3` CLI because a Rust binding was not cached. Protocol parsing, real +mail, two-way flags, arbitrary database corruption, process-exit injection, +and account/UI integration remain outside the proof. + +## Skeptical review + +- Initial verdicts: Trial 1 `REJECTED_UNTIL_FIXED` with one Critical and three + Important findings; Trial 2 `REJECTED_UNTIL_FIXED` with three Important and + one Minor finding. +- The reviewer independently reproduced the bad-tail acknowledgement loss, + arithmetic panic, retry-cadence drift, probabilistic identity, caught-panic + writer poisoning, SQLite integer loss, and abandoned staged rows. +- One repair round by the original developers added permanent regressions and + corrected the reports. The same reviewer then passed all affected supplied + and independent checks. +- Final verdicts: both `APPROVED_FOR_ARCHIVE_AFTER_FIXES`, with zero Critical, + Important, or Minor archive findings remaining. +- One BogKit defect is confirmed but not new: caught Fold write-transaction + panic poisoning. No new API or subsystem candidate is justified. + +## Cross-run synthesis + +- Outcome totals: 36 trials — 32 `no_fit` and four + `local_proof_only_no_production_fit`; zero production adoptions. +- Storage, durability, transaction, concurrency, and external-authority + boundaries: 30 independent trials, adding both of today's sources. +- Component-selective setup: 15 independent trials, adding the Fold-only undo + proof. The mailbox diagnostic reproducer is not counted as another scaffold + source. +- Nameable pipeline/readers remains seven. +- Caught Fold write-panic poisoning now has two independent sources. Persisted- + value codec panic remains one source. These remain the two distinct confirmed + BogKit defects. +- No new candidate is promoted for undo, mailbox projection, normal `Err` + returns, SQLite integer encoding, staging cleanup, or domain-specific APIs. +- Positioning signal: Fold can centrally persist a small single-writer state, + but full-state rewrites may lose to a simple journal; exact epoch-safe + projections remain a strong embedded-SQL workload when custom admission and + staging rules dominate. + +## Validation + +- New packages: 46 ordinary integration tests pass after archive + normalization; Trial 2's explicit release measurement passes separately. +- Formatting and strict pedantic Clippy pass for both new packages. +- Runnable evidence: all five undo CLI modes, the bounded three-way comparator, + the mailbox five-command demo, the full-shape SQLite/reference seed, replay, + concurrent snapshots, final-tail recovery, exact retry, integer-bound, + staging-cleanup, and writer-poisoning characterization pass. +- Nested lab workspace: one workspace and lockfile resolve the two unique new + packages; no child workspace, lockfile, or package-local release profile is + retained. +- BogKit root workspace: all 45 unit and documentation tests pass against + `80fd3c9a023e877fff2e5d127accca386d437af0`. +- Archive checks cover JSON validity, diff whitespace, changed-path scope, + credentials and secret-like tokens, large/binary files, symlinks, child + workspace metadata, and generated build/database/runtime residue. Every + retained change is under `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-15.md b/developer-simulation/reports/2026-08-15.md new file mode 100644 index 0000000..8ee934c --- /dev/null +++ b/developer-simulation/reports/2026-08-15.md @@ -0,0 +1,292 @@ +# Developer simulation — 2026-08-15 + + + +Two blind developers worked from separate sanitized copies of current `main`. +One evaluated a disposable incremental calculation cache for an established +financial-planning workbook engine; the other evaluated revision- and +applicability-safe offline search for an aircraft-maintenance tablet. Both +defined the existing baseline before inspecting BogKit and both reached +evidence-backed `no_fit` decisions. + +A separate skeptical reviewer rejected both initial handoffs. It independently +reproduced two restart/initialization defects in the calculation prototype and +one document-identity defect in the search reproducer. The original developers +repaired those findings test-first, and the same reviewer approved both stable +snapshots after a focused re-review. No BogKit core or existing example changed. + +## Trial 1 — Incremental calculation cache + +- Persona: financial-planning document-core maintainer with nine years of C# + and eight months of Rust +- Existing system: checksummed append-only edit journal plus a mature in-memory + full recalculator using exact signed 128-bit scaled integers +- Problem: preserve exact formula/error semantics while publishing one complete + calculated generation after small edits +- Outcome: `no_fit` +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold +- Archive: + [`runs/2026-08-15--incremental-calculation-cache`](../runs/2026-08-15--incremental-calculation-cache) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml --package financial-snapshot-trial -- --check` + - `cargo test --manifest-path developer-simulation/Cargo.toml --offline --locked --package financial-snapshot-trial --all-targets` + - `cargo clippy --manifest-path developer-simulation/Cargo.toml --offline --locked --package financial-snapshot-trial --all-targets -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: 15 acceptance tests and one demo test pass. Permanent coverage + includes exact hand-derived range and formula-replacement results, + deterministic cycle and missing-reference errors, malformed journal records, + overflow, one-way initialization, exact authoritative-journal recovery, + reopen-and-continue equality, and complete-generation application reads. +- Runnable demonstration: five clean logical builds and all five requested + chunk groupings agreed; twelve reader tasks completed 24,000 reads with zero + mixed generations. The one-way initialization and recovery-continuation + gates both report `Pass` while production-scale gates remain `NotRun`. +- Bounded storage observation: the closed three-batch store occupied 7,570 + bytes; a live store after 100 tiny full-state publications occupied + 67,115,339 bytes. These local figures are not a million-cell estimate. +- Baseline: all 18 Fold unit tests and nine Fold documentation tests pass. The + mature workbook evaluator, million-cell fixture, 10,000-batch trace, and + existing full-recalculation implementation were not present, so the trial + does not claim a production comparison. + +### Friction trail + +The public starter was evaluated first. Its source uses Fold, but its manifest +also declares ESE and ANNy, so a clean offline build reached ESE's model-download +script and failed before the demo launched. A Fold-only package then built +offline and demonstrated one transaction for a generation marker and calculated +cells plus one pinned read snapshot. + +The first implementation enumerated affected cells, validated journal records, +and used Fold for atomic full-state publication. It still fully evaluated the +tiny workbook and republished every cell after every accepted batch. The +application's twelve-reader surface used a cloned immutable snapshot behind an +adapter lock because the inspected Fold API did not expose an independently +clonable reader alongside the exclusive writer. + +Initial skeptical review found that repeated bootstrap could rewind the +sequence gate and that reopening persisted calculated state without restoring a +prior formula edit made the next batch diverge from uninterrupted execution. +The repaired lifecycle makes initialization one-way and permits resumed writes +only after caller-owned journal replay reconstructs a workbook whose calculated +snapshot exactly matches the persisted Fold snapshot. + +### Findings + +1. **Fold's useful boundary stops at bounded snapshot publication.** Category: + poor product fit. Severity: Important before and after review. Confidence: + high. Reproduction: run the package acceptance test + `range_dependency_recalculates_and_publishes_one_generation` and its demo; + they prove atomic read-back but also show full-state publication. Smallest + plausible improvement: document Fold as a persistence primitive in the + component/storage/concurrency matrix; add no workbook-specific API. +2. **The retained adapter is not an incremental performance proof.** Category: + performance problem. Severity: Important before and after review. + Confidence: high. Reproduction: inspect `Outcome::recalculated` and + `Trial::apply` in `src/lib.rs` plus the + `range_dependency_recalculates_and_publishes_one_generation` test; + affected cells are enumerated while every cell is recalculated and + republished. Smallest plausible improvement: retain the honest full-state + label and benchmark a real incremental evaluator before making a performance + claim. +3. **Direct reader and external-journal recovery boundaries remain.** Category: + missing capability. Severity: Important before and after review. Confidence: + high. Reproduction: run + `twelve_readers_never_observe_a_mixed_generation` and + `close_recover_and_continue_matches_uninterrupted_formula_trace`; both rely + on application-owned snapshot cloning or journal replay. Smallest plausible + improvement: keep these boundaries explicit in the existing capability + matrix; the evidence does not justify a new reader or recovery subsystem. +4. **The all-components starter is not a minimal offline Fold introduction.** + Category: API friction. Severity: Important before and after review. + Confidence: high. Reproduction: follow the first-build command and error in + the archived `DISCOVERY.md`, then compare it with the Fold-only package's + offline quality commands. Smallest plausible improvement: provide or + document component-selective starter manifests so Fold-only evaluation does + not initialize ESE or ANNy. +5. **Repeated initialization and incomplete restart reconstruction broke the + first prototype.** Category: correctness defect. Severity: Important before + repair; none after repair. Confidence: high. Reproduction: run + `bootstrap_twice_rejects_without_rewinding_visible_state`, + `open_on_existing_store_rejects_without_changing_persisted_state`, and + `close_recover_and_continue_matches_uninterrupted_formula_trace`. Smallest + plausible improvement: none; the prototype now enforces one-way + initialization and exact authoritative-journal recovery, and this did not + establish a BogKit defect. +6. **Production qualification is absent.** Category: poor product fit. + Severity: Important before and after review. Confidence: high. Reproduction: + run the demo and inspect `result-manifest.json`; the production-scale gates + remain `NotRun`. Smallest plausible improvement: evaluate the production + oracle, scale, process-exit, corruption, disk-full, memory, and concurrency + gates before reconsidering adoption. + +### Decision audit + +The candidate deliberately leaves the append-only journal authoritative, uses +Fold only for disposable calculated state, compares canonical logical bytes +rather than database bytes, and requires exact journal replay before resumed +writes. It treats ESE and ANNy as exactness no-fits. A normalized per-cell +incremental evaluator, direct Fold readers, structural reference rewriting, +production formula parity, and a representative storage/compaction strategy +remain untested. Under the brief's all-gates rule, the bounded publication proof +cannot justify continued adoption. + +## Trial 2 — Revision-safe aircraft-manual search + +- Persona: aircraft-maintenance tablet search maintainer with eleven years of + Kotlin and four months of Rust +- Existing system: signed licensed packages, a separately validated canonical + parser, and SQLite full-text search followed by aircraft-applicability filtering +- Problem: improve symptom/paraphrase retrieval without ever returning an + ineligible or superseded revision +- Outcome: `no_fit` +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold for the real BM25 cutoff reproducer +- Archive: + [`runs/2026-08-15--revision-safe-aircraft-manual-search`](../runs/2026-08-15--revision-safe-aircraft-manual-search) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml --package revision-safe-manual-search-trial -- --check` + - `cargo test --manifest-path developer-simulation/Cargo.toml --offline --locked --package revision-safe-manual-search-trial --all-targets` + - `cargo clippy --manifest-path developer-simulation/Cargo.toml --offline --locked --package revision-safe-manual-search-trial --all-targets -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: ten acceptance tests and one demo test pass. Coverage includes the real + Fold BM25 candidate cutoff, complete-fixture eligibility/effective-time + selection, duplicate document and ranked identity rejection, duplicate + revision and overlapping-active-revision rejection, unknown attributes, + missing metadata, deterministic ties, and metadata-order independence. +- Runnable demonstration: twenty ineligible documents outrank the only eligible + document. Global top-20 then filtering returns zero; requesting the complete + 21-match toy ranking and filtering before the final top ten returns the one + eligible revision with exact evidence + `AMM-32|32-40-21|R-21|1700000000..1800000000`. +- Baseline comparison: this reproduces the generic bounded-candidate-before- + filter failure mechanism. The existing SQLite implementation and private 600- + query judged set were unavailable, so no SQLite relevance or performance + comparison is claimed. + +### Friction trail + +The public search material suggested Fold for consistent lexical/semantic +views, ESE for local embeddings, and ANNy for nearest-neighbor candidates. The +developer's pre-internals hypothesis identified query-specific eligibility as +the decisive unknown. Source inspection confirmed that BM25 accepts only a +query and limit, HNSW has a fixed top-K, and the pipeline filter is fixed at +ingestion rather than supplied per query. + +ESE was not retained because a clean build fetches model and tokenizer assets +from mutable URLs without verified checksums. The bounded Fold reproducer +therefore tests only exact lexical cutoff behavior. Its deliberately exhaustive +21-row selector is a correctness proof for the toy fixture, not a scalable +solution for 2.4 million revisions. + +Initial review found that duplicate canonical document IDs were silently +overwritten in row order and repeated ranked IDs could return duplicate exact +evidence. The repaired boundary rejects both before selection and is invariant +to valid metadata row order. + +### Findings + +1. **Bounded global retrieval followed by eligibility filtering loses valid + results.** Category: missing capability. Severity: Important before and after + review. Confidence: high. Reproduction: run + `bounded_top_twenty_then_filter_loses_the_lower_eligible_revision` and the + demo; real Fold top-20 returns no eligible row while the complete 21-row toy + ranking returns the eligible revision. Smallest plausible improvement: + document the cutoff limitation, then evaluate a narrow allowed-key or + predicate surface rather than a general search subsystem. +2. **Validated replaceable generations and the required reader/updater topology + are absent.** Category: missing capability. Severity: Important before and + after review. Confidence: high. Reproduction: inspect `machine-report.json`; the + generation, crash, and concurrent-reader gates are recorded as unsupported + or unrun. Smallest plausible improvement: add these facts to the existing + storage/concurrency capability matrix; do not add a generation manager from + this trial alone. +3. **ESE's first-build model boundary is not hermetic or adequately documented.** + Category: documentation gap. Severity: Important before and after review. + Confidence: high. Reproduction: follow the ESE first-build trace in the + archived `DISCOVERY.md`, which reaches mutable model and tokenizer URLs + without checksum verification. Smallest plausible improvement: document + model identity, language scope, cache, and first-build behavior, then pin + immutable assets and verify their checksums. +4. **Duplicate metadata and ranked identities made the first selector + order-dependent.** Category: correctness defect. Severity: Important before + repair; none after repair. Confidence: high. Reproduction: run + `duplicate_metadata_doc_id_is_rejected_regardless_of_row_order`, + `duplicate_ranked_doc_id_fails_closed`, and + `unique_metadata_row_order_does_not_change_eligibility_or_evidence`. + Smallest plausible improvement: none; the prototype now rejects both + duplicate boundaries, and this did not establish a BogKit defect. +5. **Production relevance and operational gates remain unrun.** Category: poor + product fit. Severity: Important before and after review. Confidence: high. + Reproduction: inspect `machine-report.json`; recall, latency, memory, disk, full + applicability, generation hashes, atomic activation, corruption, disk-full, + process exits, and sixteen-reader updates remain unmeasured. Smallest + plausible improvement: rerun the evaluation only with the private judged set + and the full operational fixtures. + +### Decision audit + +The no-fit decision rests on a hard eligibility boundary rather than semantic +quality. The prototype uses Fold centrally for the exact failure reproducer, +does not add ANNy or ESE merely to satisfy component coverage, and refuses to +infer generation safety from single-store transaction atomicity. Filtered ANN, +bitmap allow-lists, generation layout, model quality/licensing, and the full +private benchmark remain uncertain. Any future evaluation must begin from an +actual filtered-retrieval surface and the full safety fixtures. + +## Skeptical review + +- Initial verdicts: both `REJECTED_UNTIL_FIXED`; zero Critical, three Important, + and one Minor finding across the two prototypes. +- The reviewer independently reproduced calculation restart divergence, + sequence rewind through repeated initialization, full-evaluation wording, + order-dependent duplicate document metadata, and duplicate ranked evidence. +- One focused repair round by the original developers added permanent + regressions and corrected code, manifests, and documentation. +- Fresh scoped re-review passed all affected independent probes, all 27 new + retained tests, formatting, strict pedantic Clippy, and both demonstrations. +- Final verdicts: both `APPROVED_FOR_ARCHIVE_AFTER_FIXES`, with zero Critical, + Important, or Minor archive findings remaining. +- No BogKit correctness defect was found. The two promoted candidates are + repeated capability/documentation signals, not defects or new subsystems. + +## Cross-run synthesis + +- Outcome totals: 38 trials — 34 `no_fit` and four + `local_proof_only_no_production_fit`; zero production adoptions. +- Storage, durability, transaction, concurrency, and external-authority + boundaries: 32 independent trials, adding both of today's sources. +- Component-selective setup: 16 independent trials, adding the calculation + trial's all-components starter failure. +- Nameable pipeline/readers remains seven. +- Query-time filtered search is promoted at two independent sources: + `support-case-finder` and `revision-safe-aircraft-manual-search`. +- Hermetic ESE model assets are promoted at two independent sources using the + same pair of trials. +- Caught Fold write-panic poisoning remains at two independent sources; + persisted-value codec panic remains at one. Neither was encountered today. +- Positioning signal: Fold can atomically persist a disposable embedded view, + but it does not replace domain evaluators, external authority replay, + query-specific eligibility, generation lifecycle, or application-level + concurrency design. + +## Validation + +- New packages: 27 tests pass after archive normalization. +- Formatting and strict pedantic Clippy pass for both new packages. +- Runnable evidence: the calculation lifecycle/demo and exact Fold BM25 cutoff + demonstration pass from the normalized archive. +- Nested lab workspace: one workspace and lockfile resolve both unique new + packages; no child workspace, lockfile, or package-local release profile is + retained. +- BogKit root workspace: all 45 unit and documentation tests pass against + `80fd3c9a023e877fff2e5d127accca386d437af0`. +- Archive checks cover JSON validity, diff whitespace, changed-path scope, + credentials and secret-like tokens, large/binary files, symlinks, child + workspace metadata, and generated build/database/runtime residue. Every + retained change is under `developer-simulation/`. diff --git a/developer-simulation/reports/2026-08-16.md b/developer-simulation/reports/2026-08-16.md new file mode 100644 index 0000000..afb103a --- /dev/null +++ b/developer-simulation/reports/2026-08-16.md @@ -0,0 +1,311 @@ +# Developer simulation — 2026-08-16 + + + +Two blind developers worked from separate sanitized copies of current `main`. +One evaluated causal history compaction for a collaborative canvas; the other +evaluated offline verification and durable advancement for cryptographic +transparency checkpoints. Both established the existing baseline first and +reached evidence-backed `no_fit` decisions. + +A separate skeptical reviewer rejected both initial handoffs with three +Critical and five Important prototype or evidence findings. The original +developers repaired all eight findings test-first, and the same reviewer +approved both stable snapshots after a scoped re-review with zero Critical, +Important, or Minor findings remaining. No BogKit core or existing example +changed. + +## Trial 1 — Causal canvas-history compaction + +- Persona: collaborative-canvas synchronization maintainer with seven years of + TypeScript and Go and nine months of Rust +- Existing system: PostgreSQL operation authority plus a TypeScript reducer, + periodic full snapshots, 30-day retained operations, and bespoke compaction +- Problem: reduce history and tombstones without changing causal semantics, + reconnect behavior, operation identity, or canonical JSON +- Outcome: `no_fit` +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: Fold only for a development-time arrival-order + characterization +- Archive: + [`runs/2026-08-16--causal-canvas-compaction`](../runs/2026-08-16--causal-canvas-compaction) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml --package causal-canvas-compaction -- --check` + - `cargo test --offline --locked --manifest-path developer-simulation/Cargo.toml --package causal-canvas-compaction --all-targets` + - `cargo clippy --offline --locked --manifest-path developer-simulation/Cargo.toml --package causal-canvas-compaction --all-targets -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: 22 all-target tests pass. Permanent coverage includes causal + buffering, all six literal operation kinds, duplicate and conflicting + identity handling, tombstones, bounded input, deterministic batching, + compaction/reopen, complete-manifest integrity, and the in-process + publication returned-error model. +- Reduced oracle: 1,000/1,000 scalar comparisons pass across 50 histories and + 20 schedules, with stable digest `5a36a712...e8141`. The production + TypeScript reducer and canonical fixtures were unavailable, so this is not + production parity. +- Representative release observation: 500,000 operations completed in 776 ms + with 343 microseconds p95 for a 200-operation batch and 440,688 KiB sampled + peak RSS. The required 5,000,000-operation run was not attempted because the + architecture still retains accepted history in memory. +- Largest reduced shape: 40,000 objects and 20,000 list elements reopened in + 69 ms, with 195,440 KiB sampled peak RSS and 12,529,568 compacted bytes. +- Decisive gate: permanent operation fingerprints made the corrected retained + artifact 3,719/8,670 bytes, or 42.90%. That fails the brief's at-most-40% + requirement and strengthens the no-fit decision. + +### Friction trail + +The developer began from the public README and examples, then inspected the +smallest Fold surface needed to test the hypothesis. Fold could atomically +store ordinary keyed views, but the application still had to decide causal +winners, preserve permanent operation identity, prove tombstone safety, and +publish complete generations beside PostgreSQL. ESE and ANNy were unrelated to +the exact replay workload. + +The first stable prototype appeared to reject conflicting identity reuse and +validate generation data. Skeptical review showed that compaction discarded the +only fingerprint for an old operation ID, and that semantic manifest metadata +could be changed without touching the selected payload digests. The repair now +retains SHA-256 fingerprints for every accepted operation ID and binds every +canonical manifest byte with a separate SHA-256 file. The reviewer also found +that the initial crash and disk-full language described only ordinary returned +errors; the code and evidence now use that exact, narrower label. + +### Findings + +1. **The production semantic oracle is absent.** Category: documentation gap. + Severity: Critical for adoption before and after review. Confidence: high. + Reproduction: compare the acceptance rules in archived `BRIEF.md` with the + explicit missing TypeScript reducer and fixtures in `DISCOVERY.md` and the + package README. Smallest plausible improvement: supply an executable + production oracle and literal fixtures for every operation and conflict + boundary before another adoption evaluation. +2. **Fold does not remove causal winner selection or tombstone proofs.** + Category: poor product fit. Severity: Important before and after review. + Confidence: high. Reproduction: run + `fold_keyed_materialization_is_arrival_ordered_without_a_causal_wrapper` in + `tests/fold_comparison.rs`. Smallest plausible improvement: keep causality + application-owned; reconsider only a non-authoritative downstream view. +3. **Failure-aware consumers cannot handle several Fold storage and decode + boundaries as typed errors.** Category: API friction. Severity: Important + before and after review. Confidence: high. Reproduction: inspect + `fold/src/stream/unkeyed.rs`, `fold/src/stream/mod.rs`, + `fold/src/stream/keyed.rs`, and + `fold/src/pipeline/terminal/table.rs` for the open, commit, checkpoint, + keyspace, read, and decode `unwrap` paths, then run the package's Fold + characterization test. Smallest plausible + improvement: add documented fallible variants for existing open, keyspace, + read, decode, commit, and checkpoint boundaries while retaining convenience + wrappers where useful. +4. **Compaction initially permitted conflicting operation-ID reuse.** Category: + correctness defect. Severity: Critical before repair; none after repair. + Confidence: high. Reproduction: run + `conflicting_operation_id_reuse_remains_rejected_after_compact_reopen` in + `tests/compaction.rs`; it proves exact rejection and unchanged visible + bytes, digest, and counter through compact, publish, and reopen. Smallest + plausible improvement: none; permanent canonical fingerprints now close the + prototype defect and are counted in the failing byte gate. +5. **Manifest semantic metadata was initially unauthenticated.** Category: + correctness defect. Severity: Important before repair; none after repair. + Confidence: high. Reproduction: run + `changed_manifest_metadata_falls_back_or_fails` in `tests/publication.rs`. + Smallest plausible improvement: none; a separately stored digest now binds + all canonical manifest bytes before any field is trusted. +6. **The initial publication-fault labels exceeded the evidence.** Category: + documentation gap. Severity: Important before repair; none after repair. + Confidence: high. Reproduction: run `tests/publication.rs` and inspect the + package README's “returned-error test is not a crash test” section. Smallest + plausible improvement: none; the archive now says exactly that it models an + ordinary in-process returned error and only parses crash-schedule JSON. +7. **The retained-history design breaks the scale envelope.** Category: + performance problem. Severity: Critical before and after review. Confidence: + high. Reproduction: run the release `workload 500000` command and + `compaction_counts_permanent_identity_metadata_in_retained_byte_gate`. + Smallest plausible improvement: stream operations and externalize retained + history/fingerprints, then measure the full five-million-operation recipe + against the real oracle before reconsidering adoption. + +### Decision audit + +The baseline remains authoritative because only it has the actual production +merge semantics. Fold would add a second durable store without replacing +causal winner selection, safe collection, bounded ingress, or generation +publication. The prototype's repaired identity guarantee also causes the +retained-byte gate to fail. ESE and ANNy are exact workload no-fits. The next +responsible step is shadow replay against recorded histories and the real +TypeScript oracle, not a production read-path change. + +## Trial 2 — Transparency-checkpoint verifier + +- Persona: certificate-transparency monitor maintainer with eight years of Go + and security operations and four months of Rust +- Existing system: a trusted Go verifier and SQLite transaction that advance a + signed checkpoint, raw proof, archive cursor, and decision together +- Problem: make restart, proof diagnostics, and adversarial recovery easier to + test without weakening fail-closed cryptographic behavior +- Outcome: `no_fit` +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-16--transparency-checkpoint-verifier`](../runs/2026-08-16--transparency-checkpoint-verifier) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml --package transparency-checkpoint-verifier -- --check` + - `cargo test --offline --locked --manifest-path developer-simulation/Cargo.toml --package transparency-checkpoint-verifier --all-targets` + - `cargo clippy --offline --locked --manifest-path developer-simulation/Cargo.toml --package transparency-checkpoint-verifier --all-targets -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: 27 all-target tests pass. Coverage includes bounded parsing, RFC 8032 + Ed25519, RFC 6962-style Merkle known answers and bit flips, cursor rollback, + deterministic pending resolution, duplicate/equivocation handling, corrupt + generation reuse, checked snapshot resume, and generic returned errors after + nine completed publication stages. +- Independent review: the reviewer also checked all 2,080 non-empty old/new + size pairs through 64 leaves against an independent Merkle oracle. +- Synthetic release observation: 8,000,000 generated leaf hashes completed in + 895 ms with root `6a0bc348...be0c` and 1,818,624-byte peak RSS. This does no + decompression, production archive parsing, signature corpus, or SQLite + recovery and is not comparable with the baseline's 45-minute restart. +- Reduced verifier path: 10,000 parse/signature/proof/advance iterations had + 21-microsecond p95 after warm-up. Durable publication is excluded. +- Checked reopen: eight empty logs reopened in a reported 0 ms. The production + 4,000-checkpoint corpus and Go/SQLite state were unavailable. + +### Friction trail + +The developer identified exact auditability, recoverable storage errors, and +cursor/proof atomicity as the decisive boundaries. Fold's transactions were +plausible, but several public convenience paths panic on storage or decode +failures and the component does not expose the fault surface required by this +security evaluation. ESE and ANNy were unrelated to cryptographic proof. + +The first stable state machine passed its supplied crypto and publication +tests, but the reviewer found five lifecycle gaps: cursor rollback was accepted; +a corrupt pre-existing generation could be republished as success; stale +same-base candidates could remain pending and make bytes delivery-order +dependent; reopened state could not resume verification; and three named +failure modes were one generic returned-error model. Test-first repairs close +all five while preserving the no-fit decision and explicit production gaps. + +### Findings + +1. **Production Go, SQLite, wire-format, key, and corpus parity is absent.** + Category: poor product fit. Severity: Critical for adoption before and after + review. Confidence: high. Reproduction: compare archived `BRIEF.md` with the + missing-artifact inventory in `DISCOVERY.md` and the package README's + evidence boundaries. Smallest plausible improvement: supply read-only + production fixtures and a Go decision-ledger export, then run shadow parity. +2. **Fold's fallible storage and decode boundary is missing for this consumer.** + Category: API friction. Severity: Important before and after review. + Confidence: high. Reproduction: inspect `fold/src/stream/unkeyed.rs`, + `fold/src/stream/mod.rs`, `fold/src/stream/keyed.rs`, and + `fold/src/pipeline/terminal/table.rs`; their open, keyspace, read/decode, + commit, and checkpoint convenience paths use `unwrap` rather than typed + errors. Smallest plausible + improvement: add documented fallible variants for those existing boundaries + without creating a new security storage subsystem. +3. **Archive-cursor rollback initially advanced visible state.** Category: + correctness defect. Severity: Critical before repair; none after repair. + Confidence: high. Reproduction: run + `archive_cursor_rollback_is_rejected_without_mutation` in `tests/engine.rs`; + it proves the full snapshot unchanged and a later valid advance succeeds. + Smallest plausible improvement: none; the prototype now rejects rollback + before mutation, while cursor-to-leaf correspondence remains unimplemented. +4. **Republishing initially trusted a corrupt existing generation by name.** + Category: correctness defect. Severity: Critical before repair; none after + repair. Confidence: high. Reproduction: run + `republish_rejects_corrupt_existing_generation_and_preserves_current` in + `tests/store.rs`. Smallest plausible improvement: none; reuse now validates + the exact expected files, digests, and payload bytes before pointer mutation. +5. **Pending candidates were initially stale and order-dependent.** Category: + correctness defect. Severity: Important before repair; none after repair. + Confidence: high. Reproduction: run + `same_base_candidates_are_all_decided_after_advance` and + `equal_key_pending_delivery_orders_have_identical_bytes` in + `tests/engine.rs`. Smallest plausible improvement: none; canonical envelope + identity now completes the order and every stale candidate is decided. +6. **Reopened state initially could not resume verification.** Category: + missing capability. Severity: Important before repair; none after repair. + Confidence: high. Reproduction: run + `reopened_engine_continues_and_drains_pending_without_divergence` in + `tests/store.rs`. Smallest plausible improvement: none; checked snapshot + reconstruction now validates retained state and matches uninterrupted + continuation, with historical-proof limits disclosed. +7. **Named process-exit, disk-full, and permission claims were one model.** + Category: documentation gap. Severity: Important before repair; none after + repair. Confidence: high. Reproduction: inspect the package README's generic + completed-stage returned-error section and run the five store tests. + Smallest plausible improvement: none; the archive now excludes child kill, + partial write, real OS errors, and power loss instead of naming them as + tested. + +### Decision audit + +The Go/SQLite baseline wins by default because the actual implementation and +corpus were absent. The Rust package is a useful characterization of bounded +parsing, Ed25519, small-tree consistency, deterministic decisions, and checked +publication, but it cannot establish production format, key, archive, recovery, +or migration parity. Fold's panic-oriented convenience boundary is unsuitable +for the required fail-closed error handling; ESE and ANNy do not address exact +cryptographic verification. No component should own advancement from this +evidence. + +## Skeptical review + +- Claims reproduced: all 49 retained trial tests, both strict lint/format + gates, both release builds, both reduced demos, the 1,000-schedule scalar + oracle, the 2,080-pair small-tree oracle, and all eight permanent fix-round + regressions. +- Claims rejected or softened: production semantic/parity claims, the full + five-million-operation and production-corpus gates, process-crash and named + I/O-failure claims, and comparisons between reduced synthetic timings and + either production baseline. +- Initial findings: three Critical and five Important prototype/evidence + defects. All were repaired by the original simulators and closed by the same + reviewer. Remaining findings: zero Critical, zero Important, zero Minor. +- Unnecessary dependencies: neither trial retained ESE or ANNy; Trial 1 keeps + Fold only as a development-time characterization, and Trial 2 uses no BogKit + dependency. +- Remaining uncertainty: actual production reducers, formats, keys, corpora, + operating systems, process exits, I/O failures, power loss, and migration + paths remain untested. + +## Cross-run synthesis + +- New evidence: both trials independently found that failure-aware consumers + cannot handle several Fold storage and decode failures as typed errors. This + promotes `fold-fallible-storage-and-decode-apis` at two sources. +- Recurring evidence: both trials retain an external authority and require + application-owned causal, cryptographic, and publication semantics. The + storage/concurrency boundary theme rises from 32 to 34 sources. +- Confirmed defects: unchanged. Caught Fold write-transaction panic poisoning + remains at two sources; the persisted-value codec panic remains at one. +- Candidate improvement: add documented fallible variants for existing Fold + open, keyspace, read/decode, commit, and checkpoint boundaries. Keep this + separate from the one-source codec correctness defect. +- Observations not promoted: the trials requested fault injection at different + layers, so no shared fault-injection feature is inferred. They do not justify + a generation manager, canvas API, cryptographic subsystem, archive codec, or + SQLite replacement. +- Positioning signal: BogKit remains strongest where embedded materialization, + text embeddings, or approximate search remove meaningful application work. + It is a poor fit when the application must retain an external authority and + still own exact causal or cryptographic semantics plus publication recovery. + +## Validation + +- Trial-specific tests: 22 causal-canvas tests and 27 transparency-verifier + tests passed from the normalized nested workspace. +- Strict lint and formatting: both new packages passed formatting and strict + pedantic Clippy; the full nested workspace passed warnings-denied Clippy. + A stronger full-workspace pedantic pass is not claimed because Rust 1.95 + reports pre-existing pedantic lint drift in older archived packages. +- Runnable demonstrations: both reduced release demos reproduced the retained + deterministic outputs and honestly scoped measurements. +- BogKit root workspace: all 45 unit and documentation tests passed against + merged `main` commit `98875446c6b9f6ae242666978e8699751935d64c`. +- Archive checks: one nested lockfile, unique package names, no child workspace + or lockfile, all changed paths under `developer-simulation/`, and no retained + secrets, databases, build output, runtime state, symlinks, or large binaries. diff --git a/developer-simulation/reports/2026-08-17.md b/developer-simulation/reports/2026-08-17.md new file mode 100644 index 0000000..f73669a --- /dev/null +++ b/developer-simulation/reports/2026-08-17.md @@ -0,0 +1,266 @@ +# Developer simulation — 2026-08-17 + + + +Two blind developers worked from separate sanitized copies of current `main`. +One evaluated corruption recovery for a binary instrument stream; the other +evaluated mergeable approximate reach rollups. Both established their existing +baseline first, considered Fold, ESE, and ANNy, used none, and reached +evidence-backed `no_fit` decisions. + +A separate skeptical reviewer rejected both initial handoffs with five +Important prototype or report findings. The original developers repaired or +honestly narrowed all five test-first. The first repair to Trial 1 introduced +one further Important correctness defect; the same reviewer reproduced it, the +developer repaired it, and a second scoped review closed it. Both final +handoffs were approved with zero Critical, Important, or Minor reviewer +findings remaining. No BogKit core or existing example changed. + +## Trial 1 — Corruption-resynchronizing instrument stream decoder + +- Persona: laboratory gateway developer with nine years of C and C++, the Rust + Book, and two small Rust command-line tools +- Existing system: one append buffer per connection, then a scan for framed + records or a connection-wide reset after corruption +- Problem: decode exact marker/length/CRC frames from arbitrary chunks, keep + memory bounded, and recover valid followers without leaking payload bytes +- Outcome: `no_fit` +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-17--corruption-resynchronizing-instrument-stream-decoder`](../runs/2026-08-17--corruption-resynchronizing-instrument-stream-decoder) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml --package instrument-decoder-trial -- --check` + - `cargo test --locked --manifest-path developer-simulation/Cargo.toml --package instrument-decoder-trial --all-targets` + - `cargo clippy --locked --manifest-path developer-simulation/Cargo.toml --package instrument-decoder-trial --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: 19 retained tests pass. They cover the CRC known answer, exact wire + layout, all single-byte split points, bounded over-limit rejection, marker + and CRC corruption, the modeled baseline, deterministic schedules, + arbitrary-byte stress, and both sides of the ambiguous nested-frame prefix. +- Full clean run: whole-frame, one-byte, and irregular schedules each emitted + 1,000,000/1,000,000 frames over 387,200,000 bytes with tuple digest + `0x227BAFFB59534865`, trace digest `0xED1C4875E1055725`, and zero diagnostics. +- Decisive damaged run: all schedules processed 25,000 corruptions, emitted no + damaged frame, and recorded only 48,942/50,000 followers by their own frame + end. This is an explicit failure of the brief's immediate-recovery gate, not + an independently counted total of followers that never appear later. +- Boundedness and performance: the 100,000-case arbitrary-byte run retained at + most 8,175 bytes and buffered at most 8,192; fresh peak RSS was 6,144,000 + bytes. In the fixed-chunk parser comparison, the slower baseline was 126.388 + MiB/s and the candidate 135.002 MiB/s. These host observations do not offset + the failed correctness requirement. + +### Friction trail + +The developer began with the root README and public examples. Fold's durable +stream and transaction model was unrelated to a synchronous no-disk framing +path; ESE and ANNy were also exact workload no-fits. A standalone parser first +passed clean and boundedness checks but waited behind a plausible, incomplete +header. The first repair scanned inside that candidate and emitted a later +CRC-valid sequence immediately. Skeptical review then supplied a clean outer +frame whose opaque payload contained an entire valid-looking inner frame: the +one-byte schedule emitted the inner sequence and lost the outer frame. + +The final conservative implementation never scans inside an incomplete +accepted-length candidate. It preserves the clean outer frame, but the exact +`02 00 13` false-header case cannot emit its first valid follower immediately. +The two byte prefixes are indistinguishable until later bytes arrive, so the +prototype and all three BogKit components are correctly classified `no_fit` +against the complete brief. + +### Findings + +1. **The wire format cannot guarantee both opaque payloads and unconditional + immediate recovery.** Category: missing capability. Severity: Important + before and after review. Confidence: high. Reproduction: run + `candidate_never_emits_valid_nested_payload_before_outer_is_proved` and + `candidate_defers_nested_followers_until_plausible_header_fails`, then read + the incompatible prefixes in archived `TRIAL_REPORT.md`. Smallest plausible + improvement: add an unambiguous boundary such as escaping or trusted record + segmentation, or relax recovery until the containing candidate resolves. +2. **Opportunistic nested emission initially corrupted a clean opaque frame.** + Category: correctness defect. Severity: Important before repair; none after + repair. Confidence: high. Reproduction: run + `candidate_never_emits_valid_nested_payload_before_outer_is_proved`; whole + and one-byte schedules now emit only outer sequence 42. Smallest plausible + improvement: none; the decoder now waits for the containing frame and keeps + the permanent regression. +3. **The modeled connection reset loses already-buffered valid data.** Category: + correctness defect. Severity: Important before and after review. Confidence: + high. Reproduction: run the release `demo` described in `README.md`; the + modeled baseline clears a damaged frame and its valid follower. Smallest + plausible improvement: replace connection-wide reset with incremental + validation and an explicit conservative recovery boundary. +4. **BogKit does not simplify synchronous exact framing.** Category: poor + product fit. Severity: Important before and after review. Confidence: high. + Reproduction: compare the brief's no-disk byte path with Fold's persistent + stream, ESE's text embeddings, and ANNy's approximate vector search in + `DISCOVERY.md`. Smallest plausible improvement: keep framing standalone and + consider Fold only for an optional downstream stream of validated, + privacy-safe diagnostics. +5. **The root onboarding leaves the valid use-none path implicit.** Category: + documentation gap. Severity: Minor before and after review. Confidence: + high. Reproduction: start from the root README with the archived binary + framing brief and compare the component descriptions with `DISCOVERY.md`. + Smallest plausible improvement: extend the existing capability-matrix + candidate with explicit exact synchronous protocol and no-fit examples. + +### Decision audit + +The conservative parser was chosen over opportunistic resynchronization because +clean opaque payload semantics are provable while early nested emission is not. +The append/search reset remains only a modeled baseline; production gateway +code and instrument captures were unavailable. The full synthetic and property +runs establish the stated generated cases and memory bounds, not production +readiness. The prototype is retained as an executable incompatibility and +recovery reproducer, not as an adoption candidate. + +## Trial 2 — Mergeable unique-installation reach rollups + +- Persona: analytics-infrastructure engineer at a business-software company + with eight years of Kotlin and SQL and four months of Rust +- Existing system: exact per-bucket hash sets merged centrally for an immutable + daily report +- Problem: estimate unique installations across eight shards with bounded, + portable state and measurable accuracy, determinism, memory, and runtime +- Outcome: `no_fit` +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-17--mergeable-unique-installation-reach-rollups`](../runs/2026-08-17--mergeable-unique-installation-reach-rollups) + +### Evidence + +- Quality commands: + - `cargo fmt --manifest-path developer-simulation/Cargo.toml --package reach-rollup-lab -- --check` + - `cargo test --locked --manifest-path developer-simulation/Cargo.toml --package reach-rollup-lab --release --all-targets --all-features` + - `cargo clippy --locked --manifest-path developer-simulation/Cargo.toml --package reach-rollup-lab --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: 21 tests pass and one benchmark wrapper remains intentionally ignored + in the ordinary suite. Coverage includes exact-oracle comparison, 50 merge + orders, duplicates and self-merge, complete state-file integrity, malformed + input, safe report publication, and deterministic output. +- Accuracy: the disclosed tuning matrix recorded 0.858% median, 2.671% p95, + and 5.626% worst absolute error. A fixed held-out seed not used for further + tuning recorded 0.858%, 2.708%, and 6.124%. +- Full load: 12,000,000 records produced 10,800,000 exact unique + bucket-occurrences and 5,760 canonical rows. Direct aggregation, eight-shard + aggregation, 50 merge orders, duplicate replay, and repeated state/report + bytes agree; corrected state digest is `ecda69b5ba1467d1`. +- Normalized-workspace host comparison: after one earlier warm-up, three exact + runs took 1.03, 1.09, and 1.06 seconds, while three candidate runs took 0.54, + 0.53, and 0.53 seconds. A separate current-profile measurement recorded + 49,594,368-byte candidate peak RSS versus 645,136,384 bytes for exact, more + than 13 times lower. Both paths were single-threaded, but the host had 14 + cores and the 128 MiB ceiling was measured rather than container-enforced. + +### Friction trail + +The exact hash-set baseline established correctness first. A 4,096-register, +4,124-byte HyperLogLog-style state then passed merge, state-size, and +determinism tests. The requested tuning matrix narrowly failed p95 before the +estimator transition was changed; that matrix is therefore labeled tuning +evidence, and a separate fixed seed is retained as held-out evidence. + +The first state-file format checksummed each nested sketch but did not bind the +outer tenant/hour key. Skeptical review changed one valid tenant or hour into +another and the decoder accepted the reassigned state. Version 2 now checksums +the complete header, count, every key, and every nested state before +constructing a rollup. The reviewer also corrected the component analysis: +Fold's positive-only aggregate can express local HLL updates, but the public +surface does not demonstrate the required portable external partial-state merge +and adds persistence and transactions that this immutable batch does not need. + +### Findings + +1. **The first state format did not bind keys to states.** Category: + correctness defect. Severity: Important before repair; none after repair. + Confidence: high. Reproduction: run the valid-window tenant and hour + mutation regressions in `tests/state_file.rs`; version 2 rejects both before + returning a rollup. Smallest plausible improvement: none; the versioned + complete-file checksum and permanent regressions close the prototype defect. +2. **BogKit does not demonstrate the required portable external shard merge.** + Category: poor product fit. Severity: Important before and after review. + Confidence: high. Reproduction: run the eight-shard and 50-order tests, then + compare their portable register-wise merge boundary with the public Fold + entry points described in `DISCOVERY.md`. Smallest plausible improvement: + keep this proof standalone; do not infer a new merge subsystem from one + trial. +3. **Public onboarding does not state external partial-state merge support.** + Category: documentation gap. Severity: Important before and after review. + Confidence: high. Reproduction: start from the root README and public + starter/timeseries examples, then compare them with the exact external merge + exercised by `tests/load_contract.rs`. Smallest plausible improvement: + extend the existing capability matrix to state append-only aggregation, + retraction, portable serialization, external merge, and state-bound support. + +### Decision audit + +The standalone sketch is acceptable only for the brief's advisory immutable +daily report; it is not exact and must not be used for billing, access, fraud, +quotas, privacy, or contracts. Fold was rejected because portable shard-state +exchange remains application-owned and the batch proof needs no durable store. +The held-out run uses a different fixed seed from the same generator family, +not a production distribution or independently preregistered study. OS-level +power loss, real filesystem errors, skewed production identifiers, and the +target four-core container remain untested. + +## Skeptical review + +- Claims reproduced: all 19 Trial 1 tests, 21 Trial 2 tests, two independent + parser probes, valid-range state-key mutation probes, both strict lint and + formatting gates, full clean/damaged decoder evidence, the 12-million-record + load, accuracy files, deterministic merge orders, host RSS, and both + comparisons. +- Claims rejected or softened: universal immediate follower recovery, + opportunistic scanning inside opaque payload, the initial unbound state-file + checksum, the claim that Fold cannot express positive-only HLL updates, and + outcome/finding labels that exceeded the policy. +- Initial findings: zero Critical, five Important, and zero Minor. The first + Trial 1 repair introduced one further Important correctness defect. All six + were repaired or explicitly converted into the honest no-fit boundary and + closed by the same reviewer. Remaining reviewer findings: zero Critical, + zero Important, zero Minor. +- Unnecessary dependencies: neither trial retains Fold, ESE, ANNy, or any + third-party dependency. +- Remaining uncertainty: real instrument traffic, production gateway behavior, + independent statistical distributions, target containers, enforced memory + caps, and power-loss durability remain untested. + +## Cross-run synthesis + +- New evidence: no new BogKit correctness defect or candidate crossed the + promotion threshold. +- Recurring evidence: both trials are exact bounded transformations whose key + semantics remain outside durable materialization. The existing + storage/concurrency-boundary theme rises from 34 to 36 sources and now + includes exact binary framing and externally mergeable immutable rollups. +- Candidate improvements: the existing capability-matrix candidate should + state protocol hot-path, portable accumulator, and explicit no-fit + boundaries. No new decoder, HLL, external-merge, Fold-batching, publisher, + CRC, or generic use-none subsystem is justified. +- Observations not promoted: a positive-only Fold aggregate can represent the + local HLL update, but external state exchange and transaction sizing are + one-source observations. The decoder's wire ambiguity is protocol-specific. +- Positioning signal: BogKit is useful when embedded materialization, text + embedding, or approximate search removes meaningful application work. It is + a poor fit for exact synchronous parsing or a small immutable batch whose + decisive portable merge stays application-owned. + +## Validation + +- Trial-specific tests: 19 decoder tests and 21 rollup tests passed from the + normalized nested workspace; the rollup benchmark wrapper remains explicitly + ignored in the ordinary suite and was run separately. +- Strict lint and formatting: both new packages passed formatting and strict + pedantic Clippy. The full nested workspace passed warnings-denied Clippy. +- Runnable demonstrations: the decoder demo/property/benchmark and the rollup + demo/load/comparison reproduced their retained deterministic evidence. +- BogKit root workspace: all 45 unit and documentation tests passed against + merged current-main commit `20f2ca50d5d06f51edfe8b8570c0fb48caf9eb81`. +- Archive checks: one nested lockfile, 42 unique package names, no child + workspace or lockfile, all changed paths under `developer-simulation/`, and + no retained secrets, databases, build output, runtime state, symlinks, or + large binaries. diff --git a/developer-simulation/reports/2026-08-18.md b/developer-simulation/reports/2026-08-18.md new file mode 100644 index 0000000..7b36993 --- /dev/null +++ b/developer-simulation/reports/2026-08-18.md @@ -0,0 +1,281 @@ +# Developer simulation — 2026-08-18 + + + +Two blind developers worked from separate sanitized copies of current +`main`. One evaluated exact laboratory-unit conversion admission; the other +evaluated deterministic freight-capacity batch clearing. Both established +their existing baseline first, considered Fold, ESE, and ANNy, used none, and +reached evidence-backed `no_fit` decisions. + +A separate skeptical reviewer rejected each initial handoff with four +Important and one Minor prototype or report finding. The original developers +repaired all ten findings test-first. Scoped re-review approved both repaired +snapshots for archive with zero Critical, Important, or Minor reviewer +discrepancies remaining. Trial 1 separately retains one honest Important +performance finding because its million-row macOS run exceeded the 256 MiB +gate. No BogKit core or existing example changed. + +## Trial 1 — Exact laboratory-unit conversion admission gate + +- Persona: healthcare-interface developer with eight years of Java and SQL and + four months of Rust +- Existing system: authoritative PostgreSQL reference tables and a Java 17 + conversion service, with a drifting Node.js advisory preflight +- Problem: validate an immutable reference snapshot, apply exact rational + affine conversions with one round-half-to-even step, reject unsafe rows, and + atomically publish a canonical preview +- Outcome: `no_fit` +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-18--laboratory-unit-conversion-gate`](../runs/2026-08-18--laboratory-unit-conversion-gate) + +### Evidence + +- Normalized quality commands: + - `cargo test --offline --locked --manifest-path developer-simulation/Cargo.toml --package lab-unit-gate --all-targets` + - `cargo fmt --manifest-path developer-simulation/Cargo.toml --package lab-unit-gate -- --check` + - `cargo clippy --offline --locked --manifest-path developer-simulation/Cargo.toml --package lab-unit-gate --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: 20 retained tests pass. They cover exact decimal parsing, positive and + negative half-even boundaries, 20,000 independent slow-reference arithmetic + comparisons, bounded lines, reference validation, canonical sorting, + duplicate IDs, alias identities, relative publication, explicit + pre/post-rename state, deterministic shuffles, and the real CLI. +- Full synthetic shape: five one-million-row shuffles and three repeats each + produced 975,000 conversions, 25,000 rejections, a 93,065,000-byte report, + and SHA-256 + `b9c8adf790c0717818cdaaeb83d5bbb612aa3780eb60e4eba3d97732c1446eb5`. +- Independent reviewer observation: a fresh full run took 1.17 seconds and + 290,521,088 bytes maximum resident memory on macOS. The memory result is + 22,085,632 bytes above 256 MiB, so the resource gate fails on this host. +- Normalized runnable demo: 10,000 rows produced 9,750 conversions and 250 + rejections with digest + `256be30a4b0845bb13d4e999587042cdd60f9c6512f2ffc1e8da8a54379b71a0`. + Machine output says `run_status:"complete"` and + `acceptance:"unverified"`, never acceptance pass. + +### Friction trail + +The developer began with the root README and public examples. Fold's durable +incremental state did not remove exact decimal grammar, checked rational +arithmetic, full reference validation, duplicate detection, canonical sorting, +or atomic file publication. ESE and ANNy were unrelated to exact numeric +conversion. A standalone safe-Rust batch therefore became the baseline and +candidate. + +Initial tests proved the numeric core, but review found that the report path +could alias and replace an immutable input, a bare relative path returned an +error after publication, and the benchmark printed `pass` despite a measured +memory failure and missing authority. Review also rejected sixty duplicate +parser fixtures and the classification of absent Java/Linux evidence as a +product capability. The repaired boundary rejects direct, resolved, hard-link, +and sibling-temporary aliases; opens the parent directory before rename; +distinguishes post-rename durability uncertainty; separates completion from +acceptance; and retains one meaningful negative fixture. + +### Findings + +1. **The million-row candidate exceeds the 256 MiB gate.** Category: + performance problem. Prior severity: Important. Current severity: + Important. Confidence: high. Reproduction: run the full-shape commands in + archived `README.md` under `/usr/bin/time -l`; the reviewer measured + 290,521,088 bytes. Smallest plausible improvement: replace in-memory report + sorting and duplicate retention with a bounded external merge that detects + duplicates before final publication. +2. **The original output path could overwrite an immutable input.** Category: + correctness defect. Prior severity: Important. Current severity: None. + Confidence: high. Reproduction: run + `output_cannot_alias_observations_or_a_resolved_reference_path` and + `existing_output_identity_and_temporary_aliases_are_rejected` in + `tests/gate.rs`. Smallest plausible improvement: none; resolved-path, + existing-file-identity, and sibling-temporary guards plus regressions are + retained. +3. **The original relative path reported failure after publication.** + Category: correctness defect. Prior severity: Important. Current severity: + None. Confidence: high. Reproduction: run + `relative_output_path_publishes_successfully` and + `post_rename_uncertainty_is_distinct_from_prepublication_failure`. + Smallest plausible improvement: none; `.` normalization, an early retained + directory handle, and distinct lifecycle states are implemented. +4. **The original benchmark false-passed failed or missing gates.** Category: + correctness defect. Prior severity: Important. Current severity: None. + Confidence: high. Reproduction: run + `measured_over_limit_memory_can_never_emit_acceptance_pass` and inspect the + demo JSON. Smallest plausible improvement: none; completion and acceptance + are separate and missing authorities force `unverified`. +5. **BogKit does not simplify this exact immutable conversion batch.** + Category: poor product fit. Prior severity: Important. Current severity: + Important. Confidence: high. Reproduction: compare the brief's exact + file-to-file boundary with the component assessment in `DISCOVERY.md`. + Smallest plausible improvement: keep the converter standalone and extend + the existing capability matrix with exact numeric admission and no-component + examples. + +### Decision audit + +The prototype parses decimals without binary floating point and rounds only +once. It preserves an external Java/PostgreSQL authority and is not a clinical +decision system. In-memory canonical sorting was retained as the smallest +inspectable proof even after the measured memory failure; this makes the +`no_fit` result stronger rather than authorizing adoption. Output-alias races +after preflight, process kills, disk-full behavior, power loss, real clinical +data, signed fixtures, authoritative reason codes, the Java evaluator, and the +declared Linux host remain outside the evidence. + +## Trial 2 — Deterministic freight-capacity batch clearing + +- Persona: freight-marketplace backend developer with six years of Ruby and + PostgreSQL and five months of Rust +- Existing system: a Ruby worker clears immutable PostgreSQL snapshots into an + advisory operator proposal +- Problem: apply exact price-time priority, conserve integer quantities and + checked gross value, ignore input order, and atomically publish one canonical + proposal +- Outcome: `no_fit` +- BogKit components considered: Fold, ESE, and ANNy +- BogKit components used: none +- Archive: + [`runs/2026-08-18--freight-capacity-batch-clearing`](../runs/2026-08-18--freight-capacity-batch-clearing) + +### Evidence + +- Normalized quality commands: + - `cargo test --offline --locked --manifest-path developer-simulation/Cargo.toml --package freight-clearing --all-targets` + - `cargo fmt --manifest-path developer-simulation/Cargo.toml --package freight-clearing -- --check` + - `cargo clippy --offline --locked --manifest-path developer-simulation/Cargo.toml --package freight-clearing --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic` +- Tests: 33 retained integration tests pass. They include 40 literal golden + markets, 10,000 fixed-seed markets compared fill-for-fill with an independent + scanner, ordering/conservation/extrema checks, bounded input, required + manifest integrity, alias identities, explicit publication state, shuffles, + and the real CLI. +- Full synthetic shape: all ten 650,000-order shuffles and three repeats + produced 498,521 fills, checked gross value + `312220517981477594`, an 80,960,196-byte proposal, and SHA-256 + `e7ce11984ca3e04197df9dd43013361b123e5ecf4b877f3a36f0a08dd64ebcad`. +- Independent reviewer observation: a fresh full run took 0.97 seconds and + 481,558,528 bytes maximum resident memory on macOS, below 512 MiB on this + host. This is not the declared two-core Linux or Ruby comparison. +- Normalized runnable demo: 6,500 orders in 50 markets produced 4,967 fills, + checked gross value `3075163243940483`, an 806,647-byte proposal, and + SHA-256 + `d24a6f1898c9ea72b069ff4a3bf2e6a0d3d8de290449609d6e05377db1740cd3`. + +### Friction trail + +The exact Ruby specification established the ordering and conservation +baseline. Fold would add persistent state to an immutable batch while leaving +validation, sorting, matching, checked arithmetic, and file publication custom; +ESE and ANNy were unrelated. The standalone matcher initially retained a +duplicate global proposal while validating and exceeded 512 MiB. Market-at-a- +time validation reduced host RSS below that threshold without changing bytes. + +Skeptical review then found that the proposal could overwrite either immutable +input, relative publication returned failure after rename, the 4 KiB line limit +was enforced only after buffering a one-MiB line, and required digest/gross +integrity fields were optional. The repaired boundary rejects direct, +resolved, and hard-link aliases; retains the parent directory handle; exposes +pre-rename versus post-rename state; stops after 4,097 consumed bytes; and +requires both manifest integrity fields before parsing or publication. + +### Findings + +1. **The original proposal path could overwrite an immutable input.** + Category: correctness defect. Prior severity: Important. Current severity: + None. Confidence: high. Reproduction: run the three alias tests in + `tests/reviewer_repairs.rs`; both source digests remain unchanged. + Smallest plausible improvement: none; resolved-path and existing-file- + identity rejection is retained at the run boundary. +2. **The original relative proposal reported failure after publication.** + Category: correctness defect. Prior severity: Important. Current severity: + None. Confidence: high. Reproduction: run the relative, pre-rename, and + post-rename tests in `tests/reviewer_repairs.rs`. Smallest plausible + improvement: none; `.` normalization, an early retained directory handle, + and explicit `NotPublished` versus + `PublishedDurabilityUncertain` states are implemented. +3. **The original line limit followed unbounded buffering.** Category: + performance problem. Prior severity: Important. Current severity: None. + Confidence: high. Reproduction: run + `one_mib_overlong_line_is_rejected_without_consuming_beyond_the_bound`; + rejection occurs after at most 4,097 bytes. Smallest plausible improvement: + none; the bounded `fill_buf` reader and regression are retained. +4. **The original evidence boundary accepted missing integrity fields.** + Category: correctness defect. Prior severity: Important. Current severity: + None. Confidence: high. Reproduction: run the six missing, null, short, and + nonhex integrity tests in `tests/reviewer_repairs.rs`. Smallest plausible + improvement: none; the CLI requires a valid 64-hex order digest and expected + checked gross value. +5. **BogKit does not simplify exact one-shot market clearing.** Category: + poor product fit. Prior severity: Important. Current severity: Important. + Confidence: high. Reproduction: compare the brief's immutable price-time + pass with `DISCOVERY.md` and the retained ordinary ordered-collection + implementation. Smallest plausible improvement: keep the matcher standalone + and state this batch/no-component boundary in the existing capability + matrix. + +### Decision audit + +The exact seller-ask matcher was chosen over an alternative auction design +because the signed product specification fixes price-time behavior. The +proposal remains advisory; it does not book, reserve, charge, notify, or write +to PostgreSQL. Market-at-a-time validation fixes the candidate's duplicated +proposal memory without changing canonical output. The production Ruby +evaluator, authoritative output, three same-host Ruby timings, supplied +negative corpus, declared Linux runner, process kills, disk-full, power loss, +and concurrent hostile path replacement remain untested. + +## Skeptical review + +- Initial verdicts: both `REJECTED_UNTIL_FIXED`, each with zero Critical, + four Important, and one Minor finding. +- Claims reproduced: exact arithmetic and half-even behavior; slow-reference + comparisons; ordering, seller-ask pricing, conservation, and checked gross + value; full deterministic shapes; host RSS; and all alias, publication, + bounded-line, and integrity failures. +- Claims rejected or softened: Trial 1's acceptance pass and missing-authority + capability finding; Trial 2's unbounded line claim and optional integrity; + supplied-corpus implications; target-host, Java, Ruby, and production + conclusions. +- Repair-round verdicts: both `APPROVED_FOR_ARCHIVE`; all eight Important and + two Minor review findings were fixed with zero reviewer discrepancies + remaining. +- Unnecessary code or dependencies: Trial 1's sixty duplicate fixtures were + reduced to one named case. Neither archive retains Fold, ESE, ANNy, a + database, or an unjustified component. +- Remaining uncertainty: authoritative Java/Ruby behavior, signed fixtures, + target Linux machines, cross-language performance, process termination, + actual I/O failures, and power-loss durability remain unverified. + +## Cross-run synthesis + +- New evidence: no new BogKit correctness defect or candidate crossed the + promotion threshold. +- Recurring evidence: both trials are exact immutable file transforms whose + domain semantics and publication remain application-owned. The existing + storage/concurrency-boundary theme rises from 36 to 38 sources. +- Candidate improvements: the existing capability matrix should state exact + numeric admission, deterministic batch clearing, and explicit no-component + boundaries. No decimal engine, unit system, market-clearing engine, generic + publisher, manifest service, or bounded-line subsystem is justified. +- Observations not promoted: bounded external sorting and market-at-a-time + validation are workload-specific implementation choices. The repaired atomic + publishers are prototype code, not a new BogKit subsystem proposal. +- Positioning signal: BogKit is useful when embedded materialization, text + embeddings, or approximate search remove meaningful application work. It is + a poor fit for one-shot exact transforms that retain external authority and + still own every decisive rule and publication boundary. + +## Validation + +- Trial-specific tests: 20 laboratory-gate tests and 33 freight-clearing + integration tests passed from the normalized nested workspace. +- Strict lint and formatting: both new packages passed formatting and strict + pedantic Clippy; the full nested workspace passed warnings-denied Clippy. +- Runnable demonstrations: both normalized packages produced the deterministic + small-run counts and digests above. +- BogKit root workspace: all 45 unit and documentation tests passed against + current-main commit `20f2ca50d5d06f51edfe8b8570c0fb48caf9eb81`. +- Archive checks: one nested lockfile, 44 unique package names, no child + workspace or lockfile, all changed paths under `developer-simulation/`, and + no retained secrets, databases, build output, runtime state, symlinks, or + large binaries. diff --git a/developer-simulation/runs/2026-07-28--offline-reconciliation/Cargo.toml b/developer-simulation/runs/2026-07-28--offline-reconciliation/Cargo.toml new file mode 100644 index 0000000..4a074fe --- /dev/null +++ b/developer-simulation/runs/2026-07-28--offline-reconciliation/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "offline-reconciliation" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +fold = { path = "../../../fold" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "warn" +pedantic = "allow" diff --git a/developer-simulation/runs/2026-07-28--offline-reconciliation/README.md b/developer-simulation/runs/2026-07-28--offline-reconciliation/README.md new file mode 100644 index 0000000..a4f9074 --- /dev/null +++ b/developer-simulation/runs/2026-07-28--offline-reconciliation/README.md @@ -0,0 +1,205 @@ +# Offline warehouse reconciliation evaluation + +## Outcome + +BogKit is usable as a local proof store, but this trial did not demonstrate a +BogKit-specific advantage and it is not a fit for the required production +architecture. + +Fold can persist immutable operation records atomically and support a +deterministic local projection. The prototype scans all stored operations and +recomputes its snapshot, so it does not demonstrate incremental +materialization. It also lacks a direct Fjall, SQLite, or PostgreSQL control. +Fold's embedded single-writer store does not replace the required PostgreSQL +source of truth or horizontally deployed API. The production recommendation is +to keep the algorithm and tests, but implement them in PostgreSQL. + +## Persona and existing system + +I approached BogKit as Mateo, a warehouse device developer seeing it for the +first time. I have six months of Rust experience after working in Go and +Kotlin. + +Forty Android scanners currently store pallet rows in SQLite and upload those +rows to a central Rust/PostgreSQL API. Devices can remain offline for 12 hours. +Duplicate, reordered, interrupted, or concurrent uploads can silently restore +an old pallet location. + +The baseline stores immutable operations in PostgreSQL under a unique +`(device_id, sequence)` key, derives current pallet state centrally, and puts +incompatible moves into a review queue. + +## Discovery and friction trail + +1. I read the root README, then the starter, time-series, chat, and search + examples in their published order. +2. Starter and time-series made Fold's durable transactional views clear. +3. Chat exposed the intended single-owner write architecture. +4. Search exposed `KeyedStream::upsert` replacement semantics. +5. I read the documented stream, keyed stream, table, aggregation, and keyed + stream test APIs. +6. The first ordinary Cargo build could not resolve `index.crates.io`. An + offline build worked from locally cached dependencies. +7. The first interruption test proved disk rollback, but the deliberately + caught panic poisoned Fjall's writer lock. Reopening the store, as a + restarted process would, allowed the retry. +8. Strict lint found an oversized diagnostic error. Boxing the stored and + incoming operations kept the diagnostic without retaining a large error + value. + +## Prototype + +The CLI accepts JSON Lines operation batches and persists them in a Fold +`KeyedStream` keyed by `(device_id, sequence)`. Before each upsert it rejects a +different operation that reuses an existing identity. A deterministic snapshot +groups each pallet's latest operation from every device: + +- one latest location becomes the current location; +- disagreeing latest locations become an explicit conflict containing every + candidate; +- timestamps are retained as evidence but never decide ordering. + +The prototype supports `ingest`, `show`, `demo`, and `benchmark` commands. + +## Exact commands and observed results + +Run from `developer-simulation/`: + +```console +$ rustfmt --edition 2024 --check \ + runs/2026-07-28--offline-reconciliation/src/lib.rs \ + runs/2026-07-28--offline-reconciliation/src/main.rs + +$ RUSTFLAGS='-D warnings' cargo test \ + -p offline-reconciliation --all-targets --offline +running 7 tests +test result: ok. 7 passed; 0 failed + +$ RUSTFLAGS='-D warnings' cargo clippy \ + -p offline-reconciliation --all-targets --offline -- -D warnings +Finished `dev` profile + +$ cargo run -p offline-reconciliation --offline -- \ + demo /tmp/bogkit-offline-reconciliation-demo.db +first upload: 2 inserted +exact replay: 2 duplicates +thread 'main' panicked at ... Box +interrupted upload: rolled back +whole-batch retry: 2 inserted + +$ cargo run -p offline-reconciliation --offline --release -- \ + benchmark /tmp/bogkit-offline-reconciliation-benchmark.db 20000 +received=20000 inserted=20000 duplicates=0 stored=20000 pallets=1000 elapsed_ms=57 +``` + +The simulator also ran 128 shuffled arrival orders. Every order produced the +same snapshot. The exact sample replay inserted nothing, and the two disagreeing +locations for `pallet-7` remained visible as conflict candidates. + +The panic diagnostic is printed by Rust's panic hook even though the +interruption is caught and the program continues after reopening the store. + +The simulator observed 57 ms; the skeptical review observed 54 ms; final +archive verification observed 76 ms. These synthetic, machine-specific results +show only that the prototype clears five seconds. Without a direct control they +do not show that BogKit improves on Fjall, SQLite, or PostgreSQL. + +## Findings + +### Missing causality metadata + +- Category: missing capability in the scenario data +- Severity: Critical +- Confidence: High +- Finding: Device ID, local sequence, and device timestamp cannot distinguish a + genuinely concurrent cross-device move from a later edit that observed the + first move. +- Reproduction: the same pair of cross-device operation records can describe + either history. +- Minimal improvement: include a per-pallet base revision or observed-head + identifiers with every operation. Until then, conservatively review every + disagreeing cross-device frontier. + +### Production storage mismatch + +- Category: poor product fit +- Severity: High +- Confidence: High +- Finding: Fold uses an embedded single-writer store, while the required + service keeps PostgreSQL and runs multiple API instances. +- Reproduction: compare the chat example's single-owner write path with the + scenario constraints. +- Minimal improvement: document the embedded source-of-truth boundary clearly. + A PostgreSQL-backed materialization path would be an architectural addition, + not a small adapter. + +### Keyed upsert is replacement, not immutable insertion + +- Category: usage constraint +- Severity: Informational +- Confidence: High +- Finding: Reusing `(device_id, sequence)` with changed content replaces the + earlier value unless the caller checks it. This behavior is explicit in the + API and is not a defect. +- Reproduction: `KeyedStream::upsert` retracts the previous value and inserts + the replacement; the prototype's divergent-identity test guards it. +- Minimal improvement: none from one trial. The existing transactional + `get`-then-`upsert` path implements compare-and-reject safely. + +### Caught-panic test caveat + +- Category: test-harness caveat +- Severity: Informational +- Confidence: High +- Finding: The injected panic rolled back disk state but poisoned the current + embedded writer for later writes, and Rust's panic hook printed a diagnostic + even though the harness caught the panic. +- Reproduction: the interruption test must reopen the model before retrying. +- Minimal improvement: no product change from this synthetic test. A real + process restart naturally reopens the store; repeat evidence would be needed + before proposing recovery behavior. + +### Onboarding does not expose component boundaries + +- Category: documentation gap +- Severity: Medium +- Confidence: High +- Finding: The README and project generator do not quickly explain which + component fits which storage and concurrency shape. +- Minimal improvement: add a concise capability matrix and an immutable-event + example. + +### Local atomic persistence worked + +- Category: positive evidence +- Severity: Informational +- Confidence: High +- Finding: Fold supported atomic local batches, checkpointed reopen, exact + replay checks, and local ingestion under the scenario threshold. The + snapshot still performs a full scan and recomputation. + +## Decision audit + +1. **Immutable operations with caller-side identity checks — selected for the + proof.** Fold's upsert API alone would silently replace divergent content. +2. **Conservative conflict detection — selected.** The available operation + schema lacks cross-device causality, so false-positive review is safer than + silent loss. +3. **Fold as the local proof store — selected.** It exercises atomic batch, + checkpointed reopen, and deterministic read behavior with little + infrastructure. No control was built, so this is convenience evidence, not + a demonstrated advantage. +4. **Fold as the production source of truth — rejected.** It conflicts with the + fixed PostgreSQL and horizontal-deployment requirements. +5. **Timestamps for conflict resolution — rejected.** Device clocks cannot be + trusted or coordinated. + +## Limits + +- The conflict model deliberately over-reports until causality metadata exists. +- Snapshot derivation scans and recomputes all stored operations; it does not + exercise Fold's incremental materialization advantage. +- Interruption uses a caught panic and reopen, not an operating-system kill. +- No direct Fjall, SQLite, or PostgreSQL control was built. +- Warehouse-specific action compatibility is intentionally undefined. +- No review-resolution workflow is implemented. diff --git a/developer-simulation/runs/2026-07-28--offline-reconciliation/data/sample-batch.jsonl b/developer-simulation/runs/2026-07-28--offline-reconciliation/data/sample-batch.jsonl new file mode 100644 index 0000000..7b12bf1 --- /dev/null +++ b/developer-simulation/runs/2026-07-28--offline-reconciliation/data/sample-batch.jsonl @@ -0,0 +1,5 @@ +{"device_id":"scanner-01","sequence":2,"pallet_id":"pallet-7","action":"move","location":"cold-1","operator":"maya","device_timestamp_ms":1700000000002} +{"device_id":"scanner-02","sequence":1,"pallet_id":"pallet-7","action":"move","location":"freezer-9","operator":"liam","device_timestamp_ms":1699999999000} +{"device_id":"scanner-01","sequence":1,"pallet_id":"pallet-7","action":"arrive","location":"dock","operator":"maya","device_timestamp_ms":1700000000001} +{"device_id":"scanner-03","sequence":1,"pallet_id":"pallet-8","action":"arrive","location":"dock","operator":"noor","device_timestamp_ms":1700000001000} +{"device_id":"scanner-01","sequence":2,"pallet_id":"pallet-7","action":"move","location":"cold-1","operator":"maya","device_timestamp_ms":1700000000002} diff --git a/developer-simulation/runs/2026-07-28--offline-reconciliation/src/lib.rs b/developer-simulation/runs/2026-07-28--offline-reconciliation/src/lib.rs new file mode 100644 index 0000000..72aa863 --- /dev/null +++ b/developer-simulation/runs/2026-07-28--offline-reconciliation/src/lib.rs @@ -0,0 +1,635 @@ +use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; +use std::fmt; +use std::path::{Path, PathBuf}; + +use fold::pipeline::terminal; +use fold::stream::KeyedStream; +use serde::{Deserialize, Serialize}; + +pub type OperationKey = (String, u64); + +type OperationStore = + KeyedStream>; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Operation { + pub device_id: String, + pub sequence: u64, + pub pallet_id: String, + pub action: String, + pub location: String, + pub operator: String, + pub device_timestamp_ms: i64, +} + +impl Operation { + #[must_use] + pub fn key(&self) -> OperationKey { + (self.device_id.clone(), self.sequence) + } + + fn validate(&self) -> Result<(), IngestError> { + for (field, value) in [ + ("device_id", &self.device_id), + ("pallet_id", &self.pallet_id), + ("action", &self.action), + ("location", &self.location), + ("operator", &self.operator), + ] { + if value.trim().is_empty() { + return Err(IngestError::InvalidOperation { + key: self.key(), + reason: format!("{field} must not be empty"), + }); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IngestReport { + pub received: usize, + pub inserted: usize, + pub duplicate_replays: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IngestError { + InvalidOperation { + key: OperationKey, + reason: String, + }, + DivergentDuplicate { + key: OperationKey, + stored: Box, + incoming: Box, + }, + SimulatedInterruption { + after_inserts: usize, + }, + NothingToInterrupt { + requested_after: usize, + new_operations: usize, + }, +} + +impl fmt::Display for IngestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidOperation { key, reason } => { + write!(f, "invalid operation {}:{}: {reason}", key.0, key.1) + } + Self::DivergentDuplicate { key, .. } => write!( + f, + "operation identity {}:{} was reused with different content", + key.0, key.1 + ), + Self::SimulatedInterruption { after_inserts } => { + write!(f, "simulated interruption after {after_inserts} inserts") + } + Self::NothingToInterrupt { + requested_after, + new_operations, + } => write!( + f, + "cannot interrupt after {requested_after} inserts: only {new_operations} are new" + ), + } + } +} + +impl std::error::Error for IngestError {} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct Candidate { + pub device_id: String, + pub sequence: u64, + pub action: String, + pub location: String, + pub operator: String, + pub device_timestamp_ms: i64, +} + +impl From for Candidate { + fn from(operation: Operation) -> Self { + Self { + device_id: operation.device_id, + sequence: operation.sequence, + action: operation.action, + location: operation.location, + operator: operation.operator, + device_timestamp_ms: operation.device_timestamp_ms, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum PalletStatus { + Settled { + action: String, + location: String, + evidence: Vec, + }, + Conflict { + candidates: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PalletView { + pub pallet_id: String, + #[serde(flatten)] + pub status: PalletStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Snapshot { + pub operation_count: usize, + pub pallets: Vec, +} + +pub struct Model { + path: PathBuf, + store: Option, +} + +impl Model { + #[must_use] + pub fn open(path: impl AsRef) -> Self { + let path = path.as_ref().to_path_buf(); + Self { + store: Some(open_store(&path)), + path, + } + } + + pub fn ingest_batch(&mut self, batch: &[Operation]) -> Result { + let (normalized, mut duplicate_replays) = normalize_batch(batch)?; + let mut new_operations = Vec::with_capacity(normalized.len()); + let mut conflict = None; + + self.store.as_mut().expect("model store is open").wtx(|tx| { + for (key, incoming) in &normalized { + match tx.get(key) { + Some(stored) if stored == *incoming => duplicate_replays += 1, + Some(stored) => { + conflict = Some(IngestError::DivergentDuplicate { + key: key.clone(), + stored: Box::new(stored), + incoming: Box::new(incoming.clone()), + }); + return; + } + None => new_operations.push((key.clone(), incoming.clone())), + } + } + + for (key, operation) in &new_operations { + let replaced = tx.upsert(key, operation); + debug_assert!(replaced.is_none()); + } + }); + + if let Some(error) = conflict { + return Err(error); + } + + Ok(IngestReport { + received: batch.len(), + inserted: new_operations.len(), + duplicate_replays, + }) + } + + pub fn simulate_interrupted_batch( + &mut self, + batch: &[Operation], + after_inserts: usize, + ) -> Result<(), IngestError> { + let (normalized, _) = normalize_batch(batch)?; + let mut new_operations = Vec::with_capacity(normalized.len()); + let mut conflict = None; + let mut nothing_to_interrupt = None; + + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.store.as_mut().expect("model store is open").wtx(|tx| { + for (key, incoming) in &normalized { + match tx.get(key) { + Some(stored) if stored == *incoming => {} + Some(stored) => { + conflict = Some(IngestError::DivergentDuplicate { + key: key.clone(), + stored: Box::new(stored), + incoming: Box::new(incoming.clone()), + }); + return; + } + None => new_operations.push((key.clone(), incoming.clone())), + } + } + + if after_inserts == 0 || after_inserts > new_operations.len() { + nothing_to_interrupt = Some(IngestError::NothingToInterrupt { + requested_after: after_inserts, + new_operations: new_operations.len(), + }); + return; + } + + for (index, (key, operation)) in new_operations.iter().enumerate() { + tx.upsert(key, operation); + if index + 1 == after_inserts { + std::panic::panic_any(InterruptionMarker); + } + } + }); + })); + + if let Some(error) = conflict { + return Err(error); + } + if let Some(error) = nothing_to_interrupt { + return Err(error); + } + + match outcome { + Err(payload) if payload.is::() => { + // fjall's single-writer mutex is poisoned by the deliberately + // injected panic. A real process interruption drops the + // handle, so reopen here before retrying in this process. + self.store.take(); + self.store = Some(open_store(&self.path)); + Err(IngestError::SimulatedInterruption { after_inserts }) + } + Err(payload) => std::panic::resume_unwind(payload), + Ok(()) => unreachable!("a valid simulated interruption must panic"), + } + } + + #[must_use] + pub fn snapshot(&self) -> Snapshot { + let operations = self + .store + .as_ref() + .expect("model store is open") + .rtx(|table| table.iter().map(|(_, operation)| operation).collect()); + derive_snapshot(operations) + } + + pub fn checkpoint(&mut self) { + self.store + .as_mut() + .expect("model store is open") + .checkpoint(); + } +} + +#[derive(Debug)] +struct InterruptionMarker; + +fn open_store(path: &Path) -> OperationStore { + KeyedStream::new(path, terminal::Table::new("immutable_operations")) +} + +fn normalize_batch( + batch: &[Operation], +) -> Result<(BTreeMap, usize), IngestError> { + let mut operations = BTreeMap::new(); + let mut duplicate_replays = 0; + + for operation in batch { + operation.validate()?; + let key = operation.key(); + match operations.entry(key.clone()) { + Entry::Vacant(entry) => { + entry.insert(operation.clone()); + } + Entry::Occupied(entry) if entry.get() == operation => { + duplicate_replays += 1; + } + Entry::Occupied(entry) => { + return Err(IngestError::DivergentDuplicate { + key, + stored: Box::new(entry.get().clone()), + incoming: Box::new(operation.clone()), + }); + } + } + } + + Ok((operations, duplicate_replays)) +} + +fn derive_snapshot(operations: Vec) -> Snapshot { + let operation_count = operations.len(); + let mut latest_per_device: BTreeMap<(String, String), Operation> = BTreeMap::new(); + + for operation in operations { + let key = (operation.pallet_id.clone(), operation.device_id.clone()); + match latest_per_device.entry(key) { + Entry::Vacant(entry) => { + entry.insert(operation); + } + Entry::Occupied(mut entry) if operation.sequence > entry.get().sequence => { + entry.insert(operation); + } + Entry::Occupied(_) => {} + } + } + + let mut by_pallet: BTreeMap> = BTreeMap::new(); + for ((pallet_id, _), operation) in latest_per_device { + by_pallet + .entry(pallet_id) + .or_default() + .push(operation.into()); + } + + let pallets = by_pallet + .into_iter() + .map(|(pallet_id, mut candidates)| { + candidates.sort(); + let outcomes: BTreeSet<_> = candidates + .iter() + .map(|candidate| (candidate.action.clone(), candidate.location.clone())) + .collect(); + + let status = if outcomes.len() == 1 { + let (action, location) = outcomes + .into_iter() + .next() + .expect("one outcome was observed"); + PalletStatus::Settled { + action, + location, + evidence: candidates, + } + } else { + PalletStatus::Conflict { candidates } + }; + + PalletView { pallet_id, status } + }) + .collect(); + + Snapshot { + operation_count, + pallets, + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + + use super::*; + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + struct TempDb { + path: std::path::PathBuf, + } + + impl TempDb { + fn new(label: &str) -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + let counter = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "offline-reconciliation-{label}-{}-{nonce}-{counter}", + std::process::id() + )); + Self { path } + } + } + + impl Drop for TempDb { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn op( + device_id: &str, + sequence: u64, + pallet_id: &str, + action: &str, + location: &str, + ) -> Operation { + Operation { + device_id: device_id.to_string(), + sequence, + pallet_id: pallet_id.to_string(), + action: action.to_string(), + location: location.to_string(), + operator: format!("operator-{device_id}"), + device_timestamp_ms: 1_700_000_000_000 + sequence as i64, + } + } + + #[test] + fn duplicate_replay_changes_nothing() { + let db = TempDb::new("duplicates"); + let mut model = Model::open(&db.path); + let batch = vec![ + op("scanner-1", 1, "pallet-7", "arrive", "dock"), + op("scanner-1", 2, "pallet-7", "move", "cold-1"), + ]; + + let first = model.ingest_batch(&batch).expect("first upload succeeds"); + let before = model.snapshot(); + let replay = model.ingest_batch(&batch).expect("replay succeeds"); + + assert_eq!(first.inserted, 2); + assert_eq!(replay.inserted, 0); + assert_eq!(replay.duplicate_replays, 2); + assert_eq!(model.snapshot(), before); + } + + #[test] + fn divergent_identity_is_rejected_without_partial_commit() { + let db = TempDb::new("identity-reuse"); + let mut model = Model::open(&db.path); + let original = op("scanner-1", 1, "pallet-7", "move", "cold-1"); + model + .ingest_batch(std::slice::from_ref(&original)) + .expect("first upload succeeds"); + + let mut altered = original; + altered.location = "freezer-9".to_string(); + let fresh = op("scanner-2", 1, "pallet-8", "arrive", "dock"); + let before = model.snapshot(); + + let error = model + .ingest_batch(&[fresh, altered]) + .expect_err("identity reuse must be rejected"); + + assert!(matches!(error, IngestError::DivergentDuplicate { .. })); + assert_eq!(model.snapshot(), before); + } + + #[test] + fn at_least_one_hundred_random_orders_have_the_same_result() { + let operations = vec![ + op("scanner-1", 1, "pallet-a", "arrive", "dock"), + op("scanner-1", 2, "pallet-a", "move", "cold-1"), + op("scanner-2", 1, "pallet-a", "move", "freezer-2"), + op("scanner-3", 1, "pallet-a", "move", "cold-1"), + op("scanner-1", 3, "pallet-b", "arrive", "dock-2"), + op("scanner-2", 2, "pallet-b", "arrive", "dock-2"), + op("scanner-4", 1, "pallet-c", "move", "aisle-9"), + op("scanner-4", 2, "pallet-c", "move", "aisle-10"), + ]; + + let expected_db = TempDb::new("expected"); + let expected = { + let mut model = Model::open(&expected_db.path); + model + .ingest_batch(&operations) + .expect("canonical upload succeeds"); + model.snapshot() + }; + + for seed in 0..128_u64 { + let db = TempDb::new("permutation"); + let mut shuffled = operations.clone(); + shuffle(&mut shuffled, seed + 1); + let mut model = Model::open(&db.path); + let mut cursor = 0; + let mut rng = seed + 17; + + while cursor < shuffled.len() { + rng = next_random(rng); + let end = (cursor + (rng as usize % 4) + 1).min(shuffled.len()); + model + .ingest_batch(&shuffled[cursor..end]) + .expect("permuted batch succeeds"); + cursor = end; + } + + assert_eq!(model.snapshot(), expected, "seed {seed} differed"); + } + } + + #[test] + fn interrupted_batch_rolls_back_and_retry_loses_nothing() { + let db = TempDb::new("interruption"); + let mut model = Model::open(&db.path); + let batch: Vec<_> = (1..=20) + .map(|sequence| { + op( + "scanner-9", + sequence, + &format!("pallet-{sequence}"), + "arrive", + "dock", + ) + }) + .collect(); + + let interrupted = model + .simulate_interrupted_batch(&batch, 7) + .expect_err("upload is deliberately interrupted"); + assert_eq!( + interrupted, + IngestError::SimulatedInterruption { after_inserts: 7 } + ); + assert_eq!(model.snapshot().operation_count, 0); + + let retry = model.ingest_batch(&batch).expect("whole retry succeeds"); + assert_eq!(retry.inserted, 20); + assert_eq!(model.snapshot().operation_count, 20); + } + + #[test] + fn incompatible_moves_expose_both_candidates() { + let db = TempDb::new("conflict"); + let mut model = Model::open(&db.path); + model + .ingest_batch(&[ + op("scanner-1", 4, "pallet-7", "move", "cold-1"), + op("scanner-2", 8, "pallet-7", "move", "freezer-9"), + ]) + .expect("upload succeeds"); + + let snapshot = model.snapshot(); + let PalletStatus::Conflict { candidates } = &snapshot.pallets[0].status else { + panic!("expected a conflict"); + }; + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].location, "cold-1"); + assert_eq!(candidates[1].location, "freezer-9"); + } + + #[test] + fn state_survives_reopen() { + let db = TempDb::new("reopen"); + let expected = { + let mut model = Model::open(&db.path); + model + .ingest_batch(&[op("scanner-1", 1, "pallet-7", "arrive", "dock")]) + .expect("upload succeeds"); + model.checkpoint(); + model.snapshot() + }; + + let reopened = Model::open(&db.path); + assert_eq!(reopened.snapshot(), expected); + } + + #[test] + fn twenty_thousand_operations_complete_under_five_seconds() { + let db = TempDb::new("performance"); + let mut model = Model::open(&db.path); + let operations = generated_operations(20_000); + let started = Instant::now(); + + let report = model + .ingest_batch(&operations) + .expect("large upload succeeds"); + let snapshot = model.snapshot(); + let elapsed = started.elapsed(); + + assert_eq!(report.inserted, 20_000); + assert_eq!(snapshot.operation_count, 20_000); + assert!( + elapsed < Duration::from_secs(5), + "20,000 operations took {elapsed:?}" + ); + } + + fn generated_operations(count: usize) -> Vec { + (0..count) + .map(|index| { + let device = index % 40; + let sequence = (index / 40 + 1) as u64; + op( + &format!("scanner-{device:02}"), + sequence, + &format!("pallet-{:04}", index % 1_000), + "move", + &format!("zone-{:02}", (index / 1_000) % 20), + ) + }) + .collect() + } + + fn shuffle(values: &mut [T], mut state: u64) { + for index in (1..values.len()).rev() { + state = next_random(state); + values.swap(index, state as usize % (index + 1)); + } + } + + fn next_random(state: u64) -> u64 { + state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407) + } +} diff --git a/developer-simulation/runs/2026-07-28--offline-reconciliation/src/main.rs b/developer-simulation/runs/2026-07-28--offline-reconciliation/src/main.rs new file mode 100644 index 0000000..56f5b9f --- /dev/null +++ b/developer-simulation/runs/2026-07-28--offline-reconciliation/src/main.rs @@ -0,0 +1,194 @@ +use std::env; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::time::Instant; + +use offline_reconciliation::{IngestError, Model, Operation}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), Box> { + let mut args = env::args().skip(1); + match args.next().as_deref() { + Some("ingest") => { + let db = required_path(args.next(), "database path")?; + let input = required_path(args.next(), "JSON Lines batch path")?; + reject_extra(args)?; + ingest(&db, &input) + } + Some("show") => { + let db = required_path(args.next(), "database path")?; + reject_extra(args)?; + show(&db) + } + Some("demo") => { + let db = required_path(args.next(), "database path")?; + reject_extra(args)?; + demo(&db) + } + Some("benchmark") => { + let db = required_path(args.next(), "database path")?; + let count = args + .next() + .map(|value| value.parse()) + .transpose()? + .unwrap_or(20_000); + reject_extra(args)?; + benchmark(&db, count) + } + _ => { + eprintln!( + "usage:\n offline-reconciliation ingest \n \ + offline-reconciliation show \n \ + offline-reconciliation demo \n \ + offline-reconciliation benchmark [operation-count]" + ); + Err("missing or unknown command".into()) + } + } +} + +fn required_path(value: Option, description: &str) -> Result { + value + .map(PathBuf::from) + .ok_or_else(|| format!("missing {description}")) +} + +fn reject_extra(mut args: impl Iterator) -> Result<(), String> { + match args.next() { + Some(extra) => Err(format!("unexpected argument: {extra}")), + None => Ok(()), + } +} + +fn ingest(db: &Path, input: &Path) -> Result<(), Box> { + let operations = read_json_lines(input)?; + let mut model = Model::open(db); + let report = model.ingest_batch(&operations)?; + println!("{}", serde_json::to_string_pretty(&report)?); + print_snapshot(&model) +} + +fn show(db: &Path) -> Result<(), Box> { + print_snapshot(&Model::open(db)) +} + +fn demo(db: &Path) -> Result<(), Box> { + let mut model = Model::open(db); + let first = vec![ + operation("scanner-01", 1, "pallet-7", "arrive", "dock", "maya"), + operation("scanner-01", 2, "pallet-7", "move", "cold-1", "maya"), + ]; + let concurrent = vec![ + operation("scanner-02", 1, "pallet-7", "move", "freezer-9", "liam"), + operation("scanner-03", 1, "pallet-8", "arrive", "dock", "noor"), + ]; + + let first_report = model.ingest_batch(&first)?; + println!("first upload: {}", serde_json::to_string(&first_report)?); + let replay_report = model.ingest_batch(&first)?; + println!("exact replay: {}", serde_json::to_string(&replay_report)?); + + match model.simulate_interrupted_batch(&concurrent, 1) { + Err(IngestError::SimulatedInterruption { .. }) => { + println!( + "interrupted upload: rolled back; operation_count={}", + model.snapshot().operation_count + ); + } + Err(error) => return Err(error.into()), + Ok(()) => unreachable!("demo requested a valid interruption"), + } + + let retry_report = model.ingest_batch(&concurrent)?; + println!( + "whole-batch retry: {}", + serde_json::to_string(&retry_report)? + ); + print_snapshot(&model) +} + +fn benchmark(db: &Path, count: usize) -> Result<(), Box> { + let operations: Vec<_> = (0..count) + .map(|index| { + let device = index % 40; + let sequence = (index / 40 + 1) as u64; + operation( + &format!("scanner-{device:02}"), + sequence, + &format!("pallet-{:04}", index % 1_000), + "move", + &format!("zone-{:02}", (index / 1_000) % 20), + &format!("operator-{device:02}"), + ) + }) + .collect(); + let mut model = Model::open(db); + let started = Instant::now(); + let report = model.ingest_batch(&operations)?; + let snapshot = model.snapshot(); + let elapsed = started.elapsed(); + + println!( + "received={} inserted={} duplicates={} stored={} pallets={} elapsed_ms={}", + report.received, + report.inserted, + report.duplicate_replays, + snapshot.operation_count, + snapshot.pallets.len(), + elapsed.as_millis() + ); + if elapsed.as_secs_f64() >= 5.0 { + return Err(format!("performance target missed: {elapsed:?}").into()); + } + Ok(()) +} + +fn read_json_lines(path: &Path) -> Result, Box> { + let reader = BufReader::new(File::open(path)?); + let mut operations = Vec::new(); + for (index, line) in reader.lines().enumerate() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let operation = serde_json::from_str(&line) + .map_err(|error| format!("{}:{}: {error}", path.display(), index + 1))?; + operations.push(operation); + } + Ok(operations) +} + +fn print_snapshot(model: &Model) -> Result<(), Box> { + println!("{}", serde_json::to_string_pretty(&model.snapshot())?); + Ok(()) +} + +fn operation( + device_id: &str, + sequence: u64, + pallet_id: &str, + action: &str, + location: &str, + operator: &str, +) -> Operation { + Operation { + device_id: device_id.to_string(), + sequence, + pallet_id: pallet_id.to_string(), + action: action.to_string(), + location: location.to_string(), + operator: operator.to_string(), + device_timestamp_ms: 1_700_000_000_000 + sequence as i64, + } +} diff --git a/developer-simulation/runs/2026-07-28--purchase-audit/Cargo.toml b/developer-simulation/runs/2026-07-28--purchase-audit/Cargo.toml new file mode 100644 index 0000000..dc6ccd1 --- /dev/null +++ b/developer-simulation/runs/2026-07-28--purchase-audit/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "purchase-audit-comparison" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +fold = { path = "../../../fold" } +serde = { version = "1", features = ["derive"] } + +[lints.rust] +unsafe_code = "forbid" diff --git a/developer-simulation/runs/2026-07-28--purchase-audit/README.md b/developer-simulation/runs/2026-07-28--purchase-audit/README.md new file mode 100644 index 0000000..b6b4aa6 --- /dev/null +++ b/developer-simulation/runs/2026-07-28--purchase-audit/README.md @@ -0,0 +1,318 @@ +# Purchase-request audit evaluation + +## Outcome + +Use the conventional PostgreSQL audit table. BogKit is not a fit for the +required audit write path. + +Fold gives atomic transactions inside its own embedded `fjall` store. It +cannot put a PostgreSQL state mutation and a Fold audit event in the same +transaction. The included reproduction commits an approval to the +PostgreSQL-like state model, injects a failure before the Fold write, and +ends with approved current state but only the older create event. That +violates the central acceptance criterion. + +The baseline prototype passes its deterministic semantic tests. Those tests +do not prove PostgreSQL durability, database grants, crash recovery, 150-user +concurrency, seven-year storage behavior, or the production latency target. +Those items need PostgreSQL integration and load tests. + +## Persona + +I approached BogKit as Priya, a developer seeing it for the first time. I +maintain internal finance software at a 250-person manufacturer. I have three +years of Rust experience and regularly use Axum, SQLx, and PostgreSQL. + +My service stores the current state of purchase requests in PostgreSQL. +Thirty-day logs are not a sufficient audit record. Finance needs an immutable, +ordered seven-year history that includes the policy version used for each +change. + +Expected load: + +- 80,000 requests +- About 400,000 changes per year, or roughly 2.8 million changes over seven + years +- 150 concurrent users +- Less than 20 ms added write p95 +- No hosted infrastructure + +## Baseline and acceptance criteria + +The baseline is an append-only `purchase_request_audit` table in the same +PostgreSQL database as `purchase_request`. Each service mutation updates the +current row and inserts exactly one audit row in one SQL transaction. The +ordinary application role can select and insert audit rows but cannot update, +delete, or truncate them. A conventional index on `(request_id, sequence)` +serves ordered timelines. + +The required behavior is: + +1. Every successful mutation creates exactly one audit event. +2. A forced failure leaves neither the mutation nor its event. +3. Timelines are ordered and accurately capture before state, after state, + actor, amount, and policy version. +4. The ordinary application role cannot update or delete audit events. +5. A 10,000-event query completes in less than 250 ms locally. + +Cryptographic tamper evidence, migration, UI, cross-service collection, +retention automation, and production authentication are out of scope. + +The reference table and privilege shape is in +[`baseline.sql`](baseline.sql). It was not run because this prototype does not +include PostgreSQL. + +## Discovery and friction trail + +This is the order in which I learned the project. + +1. I read the repository `README.md`. It describes Fold as an incremental + programming framework that materializes changing streams into fast views. + It describes ESE as an embedding tool and ANNy as nearest-neighbor search. + Only Fold appeared relevant to an audit timeline. +2. I read `examples/starter/src/main.rs`. Its `Stream::wtx` closure looked + promising because the example says all of its materialized views commit + atomically. +3. I ran the advertised starter command. It did not run. The starter manifest + directly depends on ESE and ANNy even though its source only uses Fold. + Building ESE tried to download `model.safetensors` and failed when DNS was + unavailable. +4. I read Fold's crate documentation and stream implementation. This resolved + the main ambiguity: Fold stores state in an embedded `fjall` LSM store and + creates its own `SingleWriterTxDatabase` transaction. Its rollback guarantee + covers only that store. +5. I read the keyed stream and terminal implementations. Keyed state and + materialized views are atomic with one another because they share the Fold + store. There is no caller-owned PostgreSQL or SQLx transaction hook. +6. I read the timeseries and chat examples. They demonstrate useful + incremental views, but the chat example explicitly makes Fold the source of + truth and has one thread own all writes. That conflicts with the requirement + that PostgreSQL remain the source of truth. +7. I read the `Bag` terminal. A holder of the Fold write handle can call + `remove`, and the terminal deletes an element when its multiplicity reaches + zero. Fold has no PostgreSQL-style role grants for separating ordinary + application access from audit administration. +8. I implemented both the smallest baseline model and a failing-boundary + reproduction using the real Fold `Bag`. The result confirmed that Fold's + internal atomicity does not close the two-store failure window. + +I did not investigate ESE or ANNy further. Embeddings and nearest-neighbor +search do not address any acceptance criterion. + +## Prototype contents + +- [`src/lib.rs`](src/lib.rs) contains the deterministic single-store baseline + model, create/approve/reject/cancel operations, ordered audit queries, + injected failure, role-denial model, and the two real-Fold boundary + reproductions. +- [`src/main.rs`](src/main.rs) runs the comparison and prints the outcome. +- [`baseline.sql`](baseline.sql) records the recommended PostgreSQL table, + timeline index, and role-grant shape. +- `Cargo.lock` pins the runnable prototype dependencies. + +The model stages each current-state change and audit event before its commit +point. A forced failure returns before either becomes visible. Events have a +monotonic sequence and snapshot request amount, previous and new status, +actor, and policy version. + +For the 10,000-event check, the prototype creates 5,000 requests and applies +one approve, reject, or cancel transition to each. It then reads the first +10,000 events in global sequence order. This is a deterministic local data +structure check, not a PostgreSQL query benchmark. + +## Exact commands and results + +All commands below were run from the sanitized repository or the prototype +directory on July 28, 2026. + +### Public starter attempt + +```console +$ cargo run -p starter +error: failed to run custom build command for `ese v0.1.0` +... +cargo:warning=Downloading .../target/ese-cache/model.safetensors... +download failed: ... "failed to lookup address information: nodename nor servname provided, or not known" +``` + +Result: failed. This was a discovery issue, not evidence against Fold's audit +semantics. + +### Formatting + +```console +$ rustfmt --edition 2024 src/lib.rs src/main.rs +$ rustfmt --edition 2024 --check src/lib.rs src/main.rs +``` + +Result: passed with no output. + +I first tried `cargo fmt --all -- --check`. Because the prototype has a local +path dependency on the repository, that command also reported existing format +differences in `examples/search/src/main.rs`. I did not change those existing +files and used the two-file check above. + +### Tests with warnings denied + +```console +$ RUSTFLAGS='-D warnings' cargo test --manifest-path Cargo.toml --all-targets --offline +running 8 tests +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.19s +running 0 tests +test result: ok. 0 passed; 0 failed +``` + +The first online attempt tried to refresh the crates.io index and failed DNS. +All required dependencies were already cached, so `--offline` was the +deterministic command. + +### Strict lint check + +```console +$ RUSTFLAGS='-D warnings' cargo clippy --manifest-path Cargo.toml --all-targets --offline -- -D warnings +Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.86s +``` + +Result: passed with no warnings or lint findings. + +### Runnable comparison + +```console +$ cargo run --manifest-path Cargo.toml --offline --quiet +Decision: use the PostgreSQL baseline; BogKit is not a fit. +Baseline forced failure: request absent, audit count unchanged at 2. +Baseline timeline: [Create, Approve]. +Split-store failure: current state Approved, Fold has 1 older event. +Fold writer retraction: 0 events remain. +Local-model query: 10000 events in 889.042µs. +``` + +Result: passed. The measured query was below 250 ms on this run. It is evidence +only for the local model. + +## Acceptance results + +| Criterion | Local result | What remains unproven | +| --- | --- | --- | +| Exactly one event per successful mutation | Pass in the baseline model for create, approve, reject, and cancel | SQLx/PostgreSQL integration | +| Failure leaves neither state nor event | Pass in the baseline model | Real rollback on database, driver, and process failures | +| Ordered, accurate timeline | Pass; event sequence and policy snapshots are asserted | PostgreSQL query plan and concurrent ordering | +| Ordinary app role cannot update/delete | Pass in the model; reference SQL revokes these privileges | Grants under a real non-owner role | +| 10,000-event query under 250 ms | Pass at 889.042 microseconds on this local-model run | PostgreSQL data volume, cache state, hardware, and index plan | +| Added write p95 under 20 ms | Not tested | PostgreSQL load test with 150-user concurrency | +| Seven-year immutable storage | Structurally represented, not tested | Capacity, backup/restore, permissions, and operations over about 2.8 million rows | + +## Categorized findings + +### Architecture + +**A1 — Fixed storage and transaction boundary** + +- Severity: Critical +- Confidence: High +- Finding: Fold opens and commits its own embedded-store transaction. It cannot + atomically join a caller-owned PostgreSQL transaction, and replacing + PostgreSQL with Fold would violate a fixed scenario requirement. +- Reproduction: + `cargo test fold_sidecar_cannot_share_the_state_commit --offline` +- Observed result: the current state is `Approved`, while Fold contains only + the earlier `Create` event. +- Minimal improvement: add a capability matrix to the public documentation + that distinguishes embedded source-of-truth use from external-database + integration. A PostgreSQL-backed sink would be a substantial architectural + addition and is not proposed from this trial. + +### Scenario fit + +**S1 — A Fold writer can retract audit events** + +- Severity: High +- Confidence: High +- Finding: The scenario requires a role-protected append-only audit log, while + `Bag` is intentionally a retractable multiset. This is a no-fit reason, not a + Fold security defect. +- Reproduction: + `cargo test fold_sidecar_allows_retraction_by_its_writer --offline` +- Observed result: zero events remain after insert followed by retraction. +- Minimal improvement: none to Fold from this evidence. Use PostgreSQL grants + and the audit table designed for this requirement. + +**Q1 — The general-purpose Bag is not an audit timeline index** + +- Severity: Informational +- Confidence: High +- Finding: `Bag` documents serialized-element iteration rather than audit-log + ordering. Choosing it for this scenario requires an explicit sort and is + another fit mismatch, not an ordering defect. +- Reproduction: inspect the `fold_events.sort_by_key` call in `src/lib.rs` and + the `BagReader::iter` documentation in Fold. +- Minimal improvement: none from this trial. An append-log terminal would not + solve the transaction or role-separation requirements. + +### Developer experience + +**D1 — The advertised smallest example unexpectedly requires an embedding +model download** + +- Severity: Medium +- Confidence: High +- Finding: `examples/starter` declares ESE and ANNy dependencies even though + its source uses only Fold. The first-run starter build therefore invokes the + ESE download. +- Reproduction: `cargo run -p starter` +- Observed result: build failure while downloading `model.safetensors` because + DNS was unavailable. +- Minimal improvement: remove unused ESE and ANNy dependencies from the + starter manifest or put them behind opt-in features. + +**D2 — External transaction and concurrency limits are not surfaced early** + +- Severity: Medium +- Confidence: High +- Finding: The top-level README says writes are transactional but does not say + that the boundary is one embedded store. The chat example's single-writer + shape appears only after reading its source. +- Reproduction: follow the discovery sequence above from `README.md` to + `fold/src/stream`. +- Minimal improvement: state the storage engine, external-transaction + limitation, and single-writer architecture in the top-level Fold + description. + +### Positive evidence + +**P1 — Fold's internal transaction behavior is clear and useful within its +intended boundary** + +- Severity: Informational +- Confidence: High +- Finding: Current keyed state and Fold materialized views share one embedded + transaction, and panic handling aborts pending pipeline state. +- Reproduction: the Fold stream and keyed-stream implementations, plus the + public starter example. +- Minimal improvement: none for embedded use; the issue is fit, not a failure + of the documented internal transaction. + +## Decision audit + +1. **PostgreSQL current state plus PostgreSQL audit table — selected.** It is + the only option evaluated that can use one native transaction for the + mandatory state and event writes. It also has direct role grants and a + conventional ordered index. +2. **PostgreSQL current state plus Fold audit sidecar — rejected.** Either + commit order has a partial-success window. State-first can lose the audit + event; Fold-first can retain an event for a mutation that never committed. + Retry logic cannot remove the crash window without a shared transaction. +3. **Fold as the source of truth — rejected.** It could keep its own state and + views atomic, but it directly violates the PostgreSQL source-of-truth + requirement and would create an unnecessary migration. +4. **PostgreSQL outbox feeding Fold — rejected for this scope.** A PostgreSQL + outbox row could be atomic with the mutation, but the immutable audit table + already is that durable record. Copying it into Fold adds lag, duplicate + storage, replay, and operational work without improving the required + timeline. +5. **ESE or ANNy — not applicable.** Neither embeddings nor nearest-neighbor + search help transaction atomicity, immutability, or ordered audit queries. + +The no-fit decision does not claim Fold is generally unsuitable. It says that +its strongest feature here—atomic incremental views in one embedded +store—ends at exactly the boundary this finance system must cross. diff --git a/developer-simulation/runs/2026-07-28--purchase-audit/baseline.sql b/developer-simulation/runs/2026-07-28--purchase-audit/baseline.sql new file mode 100644 index 0000000..083894e --- /dev/null +++ b/developer-simulation/runs/2026-07-28--purchase-audit/baseline.sql @@ -0,0 +1,34 @@ +-- Reference shape for the recommended PostgreSQL baseline. +-- This file was reviewed but not executed because the prototype has no +-- PostgreSQL service. + +CREATE TABLE purchase_request ( + request_id bigint PRIMARY KEY, + amount_cents bigint NOT NULL CHECK (amount_cents >= 0), + status text NOT NULL CHECK (status IN ('draft', 'approved', 'rejected', 'cancelled')), + policy_version text NOT NULL +); + +CREATE TABLE purchase_request_audit ( + sequence bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + request_id bigint NOT NULL REFERENCES purchase_request(request_id), + action text NOT NULL CHECK (action IN ('create', 'approve', 'reject', 'cancel')), + previous_status text, + new_status text NOT NULL, + amount_cents bigint NOT NULL, + actor text NOT NULL, + policy_version text NOT NULL, + occurred_at timestamptz NOT NULL DEFAULT transaction_timestamp() +); + +CREATE INDEX purchase_request_audit_timeline + ON purchase_request_audit (request_id, sequence); + +-- Replace purchase_app with the actual non-owner application role. +REVOKE UPDATE, DELETE, TRUNCATE ON purchase_request_audit FROM purchase_app; +GRANT SELECT, INSERT ON purchase_request_audit TO purchase_app; + +-- Each service mutation must update purchase_request and insert exactly one +-- purchase_request_audit row inside the same SQL transaction. Production +-- enforcement should use one reviewed mutation function or an equivalent +-- single repository path, plus integration tests against PostgreSQL. diff --git a/developer-simulation/runs/2026-07-28--purchase-audit/src/lib.rs b/developer-simulation/runs/2026-07-28--purchase-audit/src/lib.rs new file mode 100644 index 0000000..0802086 --- /dev/null +++ b/developer-simulation/runs/2026-07-28--purchase-audit/src/lib.rs @@ -0,0 +1,533 @@ +//! A deterministic comparison of a single-store audit transaction with a +//! PostgreSQL-plus-Fold split write. +//! +//! `BaselineModel` is deliberately small. It models transaction outcomes and +//! audit semantics, not PostgreSQL durability, locking, grants, or latency. + +use std::collections::BTreeMap; +use std::fmt::{Display, Formatter}; +use std::path::Path; + +use fold::pipeline::terminal; +use fold::stream::Stream; +use serde::{Deserialize, Serialize}; + +pub type RequestId = u64; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum RequestStatus { + Draft, + Approved, + Rejected, + Cancelled, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum Action { + Create, + Approve, + Reject, + Cancel, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct PurchaseRequest { + pub id: RequestId, + pub amount_cents: u64, + pub status: RequestStatus, + pub policy_version: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct AuditEvent { + pub sequence: u64, + pub request_id: RequestId, + pub action: Action, + pub previous_status: Option, + pub new_status: RequestStatus, + pub amount_cents: u64, + pub actor: String, + pub policy_version: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FailPoint { + Never, + BeforeCommit, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AuditError { + RequestAlreadyExists(RequestId), + RequestNotFound(RequestId), + InvalidTransition { + request_id: RequestId, + from: RequestStatus, + action: Action, + }, + InjectedFailure, + AppRoleCannotModifyAudit, +} + +impl Display for AuditError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::RequestAlreadyExists(id) => write!(f, "request {id} already exists"), + Self::RequestNotFound(id) => write!(f, "request {id} does not exist"), + Self::InvalidTransition { + request_id, + from, + action, + } => write!( + f, + "request {request_id} cannot apply {action:?} from {from:?}" + ), + Self::InjectedFailure => write!(f, "failure injected before commit"), + Self::AppRoleCannotModifyAudit => { + write!(f, "the ordinary application role cannot modify audit rows") + } + } + } +} + +impl std::error::Error for AuditError {} + +/// Models current rows and append-only audit rows committed in one database +/// transaction. Changes are staged and become visible only at the commit +/// boundary. +#[derive(Default)] +pub struct BaselineModel { + requests: BTreeMap, + audit_by_sequence: BTreeMap, + sequences_by_request: BTreeMap>, + next_sequence: u64, +} + +impl BaselineModel { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn create( + &mut self, + id: RequestId, + amount_cents: u64, + actor: &str, + policy_version: &str, + fail: FailPoint, + ) -> Result<(), AuditError> { + if self.requests.contains_key(&id) { + return Err(AuditError::RequestAlreadyExists(id)); + } + + let request = PurchaseRequest { + id, + amount_cents, + status: RequestStatus::Draft, + policy_version: policy_version.to_owned(), + }; + let event = self.event_for(&request, Action::Create, None, actor, policy_version); + + if fail == FailPoint::BeforeCommit { + return Err(AuditError::InjectedFailure); + } + + self.commit(request, event); + Ok(()) + } + + pub fn approve( + &mut self, + id: RequestId, + actor: &str, + policy_version: &str, + fail: FailPoint, + ) -> Result<(), AuditError> { + self.transition( + id, + Action::Approve, + RequestStatus::Approved, + actor, + policy_version, + fail, + ) + } + + pub fn reject( + &mut self, + id: RequestId, + actor: &str, + policy_version: &str, + fail: FailPoint, + ) -> Result<(), AuditError> { + self.transition( + id, + Action::Reject, + RequestStatus::Rejected, + actor, + policy_version, + fail, + ) + } + + pub fn cancel( + &mut self, + id: RequestId, + actor: &str, + policy_version: &str, + fail: FailPoint, + ) -> Result<(), AuditError> { + self.transition( + id, + Action::Cancel, + RequestStatus::Cancelled, + actor, + policy_version, + fail, + ) + } + + fn transition( + &mut self, + id: RequestId, + action: Action, + new_status: RequestStatus, + actor: &str, + policy_version: &str, + fail: FailPoint, + ) -> Result<(), AuditError> { + let current = self + .requests + .get(&id) + .ok_or(AuditError::RequestNotFound(id))?; + + if current.status != RequestStatus::Draft { + return Err(AuditError::InvalidTransition { + request_id: id, + from: current.status, + action, + }); + } + + let previous_status = current.status; + let mut changed = current.clone(); + changed.status = new_status; + changed.policy_version = policy_version.to_owned(); + let event = self.event_for( + &changed, + action, + Some(previous_status), + actor, + policy_version, + ); + + if fail == FailPoint::BeforeCommit { + return Err(AuditError::InjectedFailure); + } + + self.commit(changed, event); + Ok(()) + } + + fn event_for( + &self, + request: &PurchaseRequest, + action: Action, + previous_status: Option, + actor: &str, + policy_version: &str, + ) -> AuditEvent { + AuditEvent { + sequence: self.next_sequence + 1, + request_id: request.id, + action, + previous_status, + new_status: request.status, + amount_cents: request.amount_cents, + actor: actor.to_owned(), + policy_version: policy_version.to_owned(), + } + } + + fn commit(&mut self, request: PurchaseRequest, event: AuditEvent) { + self.next_sequence = event.sequence; + self.sequences_by_request + .entry(request.id) + .or_default() + .push(event.sequence); + self.audit_by_sequence.insert(event.sequence, event); + self.requests.insert(request.id, request); + } + + #[must_use] + pub fn request(&self, id: RequestId) -> Option<&PurchaseRequest> { + self.requests.get(&id) + } + + #[must_use] + pub fn audit_len(&self) -> usize { + self.audit_by_sequence.len() + } + + #[must_use] + pub fn timeline(&self, id: RequestId) -> Vec<&AuditEvent> { + self.sequences_by_request + .get(&id) + .into_iter() + .flatten() + .filter_map(|sequence| self.audit_by_sequence.get(sequence)) + .collect() + } + + #[must_use] + pub fn all_timeline(&self, limit: usize) -> Vec<&AuditEvent> { + self.audit_by_sequence.values().take(limit).collect() + } + + pub fn app_role_update_audit(&mut self, _sequence: u64) -> Result<(), AuditError> { + Err(AuditError::AppRoleCannotModifyAudit) + } + + pub fn app_role_delete_audit(&mut self, _sequence: u64) -> Result<(), AuditError> { + Err(AuditError::AppRoleCannotModifyAudit) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SplitBoundaryResult { + pub current_state: RequestStatus, + pub fold_events: Vec, +} + +/// Uses the real Fold `Bag` as a durable sidecar and a deterministic stand-in +/// for a PostgreSQL current-state row. The second state mutation commits, then +/// the injected integration failure prevents the corresponding Fold write. +/// +/// This proves only the transaction-boundary mismatch. It does not model a +/// PostgreSQL driver or a process crash. +#[must_use] +pub fn reproduce_split_commit(path: &Path) -> SplitBoundaryResult { + let created = AuditEvent { + sequence: 1, + request_id: 7, + action: Action::Create, + previous_status: None, + new_status: RequestStatus::Draft, + amount_cents: 125_000, + actor: "priya".to_owned(), + policy_version: "policy-2026.1".to_owned(), + }; + + let mut fold_audit = Stream::new(path, terminal::Bag::::new("audit_events")); + fold_audit.wtx(|tx| tx.insert(&created)); + + // The PostgreSQL-like state commits first. + let current_state = RequestStatus::Approved; + // Injected failure: the matching Fold write is never called. + + let mut fold_events = fold_audit.rtx(|events| { + events + .iter() + .flat_map(|(event, multiplicity)| { + std::iter::repeat_n(event, usize::try_from(multiplicity).unwrap_or_default()) + }) + .collect::>() + }); + fold_events.sort_by_key(|event| event.sequence); + + SplitBoundaryResult { + current_state, + fold_events, + } +} + +/// Shows that a caller with a Fold write handle can retract an event. Fold +/// does not expose PostgreSQL-style table privileges or application roles. +#[must_use] +pub fn fold_retraction_count(path: &Path) -> usize { + let event = AuditEvent { + sequence: 1, + request_id: 7, + action: Action::Create, + previous_status: None, + new_status: RequestStatus::Draft, + amount_cents: 125_000, + actor: "priya".to_owned(), + policy_version: "policy-2026.1".to_owned(), + }; + + let mut fold_audit = Stream::new(path, terminal::Bag::::new("audit_events")); + fold_audit.wtx(|tx| tx.insert(&event)); + fold_audit.wtx(|tx| tx.remove(&event)); + fold_audit.rtx(|events| events.iter().count()) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::time::{Duration, Instant}; + + use super::*; + + fn clean_test_path(name: &str) -> std::path::PathBuf { + let path = std::env::current_dir() + .unwrap() + .join("target") + .join("test-data") + .join(name); + if path.exists() { + fs::remove_dir_all(&path).unwrap(); + } + path + } + + #[test] + fn every_successful_mutation_has_one_ordered_accurate_event() { + let mut db = BaselineModel::new(); + db.create(10, 50_000, "priya", "policy-2026.1", FailPoint::Never) + .unwrap(); + db.approve(10, "sam", "policy-2026.2", FailPoint::Never) + .unwrap(); + + let timeline = db.timeline(10); + assert_eq!(timeline.len(), 2); + assert_eq!(timeline[0].sequence, 1); + assert_eq!(timeline[0].action, Action::Create); + assert_eq!(timeline[0].previous_status, None); + assert_eq!(timeline[0].new_status, RequestStatus::Draft); + assert_eq!(timeline[0].policy_version, "policy-2026.1"); + assert_eq!(timeline[1].sequence, 2); + assert_eq!(timeline[1].action, Action::Approve); + assert_eq!(timeline[1].previous_status, Some(RequestStatus::Draft)); + assert_eq!(timeline[1].new_status, RequestStatus::Approved); + assert_eq!(timeline[1].policy_version, "policy-2026.2"); + } + + #[test] + fn forced_failure_commits_neither_state_nor_event() { + let mut db = BaselineModel::new(); + let result = db.create( + 11, + 75_000, + "priya", + "policy-2026.1", + FailPoint::BeforeCommit, + ); + + assert_eq!(result, Err(AuditError::InjectedFailure)); + assert!(db.request(11).is_none()); + assert_eq!(db.audit_len(), 0); + } + + #[test] + fn failed_transition_preserves_prior_state_and_timeline() { + let mut db = BaselineModel::new(); + db.create(12, 99_000, "priya", "policy-2026.1", FailPoint::Never) + .unwrap(); + let result = db.reject(12, "sam", "policy-2026.2", FailPoint::BeforeCommit); + + assert_eq!(result, Err(AuditError::InjectedFailure)); + assert_eq!( + db.request(12).map(|request| request.status), + Some(RequestStatus::Draft) + ); + assert_eq!(db.timeline(12).len(), 1); + } + + #[test] + fn approve_reject_and_cancel_are_terminal_transitions() { + let mut db = BaselineModel::new(); + for id in 20..=22 { + db.create(id, 10_000, "priya", "p1", FailPoint::Never) + .unwrap(); + } + db.approve(20, "sam", "p2", FailPoint::Never).unwrap(); + db.reject(21, "sam", "p2", FailPoint::Never).unwrap(); + db.cancel(22, "priya", "p1", FailPoint::Never).unwrap(); + + assert_eq!( + db.request(20).map(|request| request.status), + Some(RequestStatus::Approved) + ); + assert_eq!( + db.request(21).map(|request| request.status), + Some(RequestStatus::Rejected) + ); + assert_eq!( + db.request(22).map(|request| request.status), + Some(RequestStatus::Cancelled) + ); + assert!(matches!( + db.cancel(20, "priya", "p2", FailPoint::Never), + Err(AuditError::InvalidTransition { .. }) + )); + assert_eq!(db.timeline(20).len(), 2); + } + + #[test] + fn app_role_cannot_update_or_delete_audit_events() { + let mut db = BaselineModel::new(); + db.create(30, 15_000, "priya", "p1", FailPoint::Never) + .unwrap(); + + assert_eq!( + db.app_role_update_audit(1), + Err(AuditError::AppRoleCannotModifyAudit) + ); + assert_eq!( + db.app_role_delete_audit(1), + Err(AuditError::AppRoleCannotModifyAudit) + ); + assert_eq!(db.audit_len(), 1); + } + + #[test] + fn ten_thousand_event_query_is_under_250ms_in_the_local_model() { + let mut db = BaselineModel::new(); + for id in 1..=5_000 { + db.create(id, id * 100, "seed", "p1", FailPoint::Never) + .unwrap(); + match id % 3 { + 0 => db.approve(id, "seed", "p2", FailPoint::Never).unwrap(), + 1 => db.reject(id, "seed", "p2", FailPoint::Never).unwrap(), + _ => db.cancel(id, "seed", "p2", FailPoint::Never).unwrap(), + } + } + + let started = Instant::now(); + let timeline = db.all_timeline(10_000); + let elapsed = started.elapsed(); + + assert_eq!(timeline.len(), 10_000); + assert!( + timeline + .windows(2) + .all(|pair| pair[0].sequence < pair[1].sequence) + ); + assert!( + elapsed < Duration::from_millis(250), + "local model query took {elapsed:?}" + ); + } + + #[test] + fn fold_sidecar_cannot_share_the_state_commit() { + let path = clean_test_path("split-commit"); + let result = reproduce_split_commit(&path); + + assert_eq!(result.current_state, RequestStatus::Approved); + assert_eq!(result.fold_events.len(), 1); + assert_eq!(result.fold_events[0].action, Action::Create); + assert_ne!( + result.fold_events.last().map(|event| event.new_status), + Some(result.current_state) + ); + } + + #[test] + fn fold_sidecar_allows_retraction_by_its_writer() { + let path = clean_test_path("retraction"); + assert_eq!(fold_retraction_count(&path), 0); + } +} diff --git a/developer-simulation/runs/2026-07-28--purchase-audit/src/main.rs b/developer-simulation/runs/2026-07-28--purchase-audit/src/main.rs new file mode 100644 index 0000000..c62b5d0 --- /dev/null +++ b/developer-simulation/runs/2026-07-28--purchase-audit/src/main.rs @@ -0,0 +1,87 @@ +use std::fs; +use std::time::Instant; + +use purchase_audit_comparison::{ + AuditError, BaselineModel, FailPoint, RequestStatus, fold_retraction_count, + reproduce_split_commit, +}; + +fn main() { + let mut baseline = BaselineModel::new(); + baseline + .create(1, 125_000, "priya", "policy-2026.1", FailPoint::Never) + .expect("create should commit"); + baseline + .approve(1, "sam", "policy-2026.2", FailPoint::Never) + .expect("approve should commit"); + + let before_failed_write = baseline.audit_len(); + let forced_failure = + baseline.create(2, 80_000, "priya", "policy-2026.1", FailPoint::BeforeCommit); + assert_eq!(forced_failure, Err(AuditError::InjectedFailure)); + assert!(baseline.request(2).is_none()); + assert_eq!(baseline.audit_len(), before_failed_write); + + let split_path = data_path("run-split"); + let split = reproduce_split_commit(&split_path); + assert_eq!(split.current_state, RequestStatus::Approved); + assert_eq!(split.fold_events.len(), 1); + + let retraction_path = data_path("run-retraction"); + let after_retraction = fold_retraction_count(&retraction_path); + assert_eq!(after_retraction, 0); + + let mut query_model = BaselineModel::new(); + for id in 1..=5_000 { + query_model + .create(id, id * 100, "seed", "p1", FailPoint::Never) + .expect("seed create should commit"); + match id % 3 { + 0 => query_model + .approve(id, "seed", "p2", FailPoint::Never) + .expect("seed approval should commit"), + 1 => query_model + .reject(id, "seed", "p2", FailPoint::Never) + .expect("seed rejection should commit"), + _ => query_model + .cancel(id, "seed", "p2", FailPoint::Never) + .expect("seed cancellation should commit"), + } + } + let started = Instant::now(); + let result_count = query_model.all_timeline(10_000).len(); + let elapsed = started.elapsed(); + + println!("Decision: use the PostgreSQL baseline; BogKit is not a fit."); + println!( + "Baseline forced failure: request absent, audit count unchanged at {}.", + baseline.audit_len() + ); + println!( + "Baseline timeline: {:?}.", + baseline + .timeline(1) + .iter() + .map(|event| event.action) + .collect::>() + ); + println!( + "Split-store failure: current state {:?}, Fold has {} older event.", + split.current_state, + split.fold_events.len() + ); + println!("Fold writer retraction: {after_retraction} events remain."); + println!("Local-model query: {result_count} events in {elapsed:?}."); +} + +fn data_path(name: &str) -> std::path::PathBuf { + let path = std::env::current_dir() + .expect("current directory should be available") + .join("target") + .join("run-data") + .join(name); + if path.exists() { + fs::remove_dir_all(&path).expect("old run data should be removable"); + } + path +} diff --git a/developer-simulation/runs/2026-07-29--edge-spool-pressure/Cargo.toml b/developer-simulation/runs/2026-07-29--edge-spool-pressure/Cargo.toml new file mode 100644 index 0000000..382b0d5 --- /dev/null +++ b/developer-simulation/runs/2026-07-29--edge-spool-pressure/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "edge-spool-pressure" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +fold = { path = "../../../fold" } +serde = { version = "1", features = ["derive"] } + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "warn" +pedantic = "allow" diff --git a/developer-simulation/runs/2026-07-29--edge-spool-pressure/EVIDENCE.md b/developer-simulation/runs/2026-07-29--edge-spool-pressure/EVIDENCE.md new file mode 100644 index 0000000..157eefa --- /dev/null +++ b/developer-simulation/runs/2026-07-29--edge-spool-pressure/EVIDENCE.md @@ -0,0 +1,426 @@ +# Blind trial report: edge spool pressure + +## Decision + +**No fit for the strict 256 MiB edge spool.** + +Fold is useful for atomic queue state, durable upload intent, and durable +drop counters. The prototype recovered every retained event in the two +representative crash cases and explained the duplicate window. + +The blocker is the disk-bound guarantee. Fold exposes a logical view of +records, but its public interface does not expose a hard allocated-byte limit +or a documented way to make eviction and compaction satisfy one. In the +minimal probe, the queue held 1,048,512 logical bytes under a 1,048,576-byte +logical limit, while the database had 3,305,472 allocated bytes. This proves +that logical accounting is not a physical cap. It does not predict exact +allocation at 256 MiB or test an external filesystem quota. + +This is a deliberately bounded no-fit result. I did not run one million +events through the Fold candidate after the quota prerequisite failed. + +## Deliverables + +- Prototype: `runs/2026-07-29--edge-spool-pressure/` +- Main executable: `runs/2026-07-29--edge-spool-pressure/src/main.rs` +- Prototype instructions: `runs/2026-07-29--edge-spool-pressure/README.md` +- This report: `runs/2026-07-29--edge-spool-pressure/EVIDENCE.md` + +The prototype uses only `fold` by a local path and `serde`. It has no +network client and makes no external writes beyond its own temporary test +directories. + +## What the prototype compares + +### Existing newline-file baseline + +The baseline is a deterministic streaming model, not a disk implementation. +It generates one million events with the required exact priority mix: + +- 850,000 debug +- 120,000 operational +- 30,000 critical + +Payloads range from 100 bytes to 8 KiB. The model rolls 4 MiB newline files, +caps retained file bytes at 256 MiB, deletes the oldest whole file, and +models the duplicate window from retrying a whole file after a mid-file +disconnect. + +Observed result: + +- Generated: 1,000,000 +- Retained: 770,212 +- Retained modeled bytes: 267,030,039, below 256 MiB +- Deleted as oldest: 229,788 +- Critical events deleted: 6,894 +- Security events deleted: 4,596 +- Hardware-failure events deleted: 2,298 +- One modeled whole-file retry exposes up to 12,064 events to duplication +- Model runtime: 79-89 ms in the final runs + +The age-only policy therefore allows bulk debug traffic to delete critical +events, and whole-file retry creates a large unexplained duplicate window +unless the daemon records the active file and offset. + +### Fold candidate + +The candidate uses one Fold stream with four durable views: + +1. A priority-ordered event bag. +2. Per-priority and per-category retained counts and logical bytes. +3. Drop counts keyed by priority, category, and reason. +4. A durable upload intent that contains the attempted batch. + +The first serialized event field is a priority rank. Fold documents that a +Bag iterates in postcard-encoded order, so reads return critical, +operational, then debug events. When a higher-priority event needs room, the +prototype removes the oldest eligible lower-priority records and increments +their drop counters in the same transaction. + +Before upload, the candidate stores and checkpoints an intent. After the +collector accepts the batch, it removes the retained events and the intent +in one transaction. If the process dies after sending but before that +transaction, every intended event remains retained. The intent gives a +conservative possible-duplicate count. + +## Verified results + +### Representative priority and quota workload + +The real Fold-backed run used 20,000 generated events and a 4 MiB logical +limit. + +- Retained: 13,319 +- Retained logical bytes: 4,194,221 +- Allocated database bytes: 6,676,480 +- Apparent database file bytes: 67,138,588 +- Critical retained: all 600 generated +- Operational retained: all 2,400 generated +- Debug retained: 10,319 +- Debug evicted for higher priority: 1,024 +- Incoming debug dropped for lack of eligible space: 5,657 +- Runtime: 8.0–9.2 seconds across reviewed and final archive runs + +The run preserves critical and operational events ahead of debug events and +accounts for every drop in this representative scope. It also shows that +logical byte accounting is not a disk cap. The demonstrated candidate stage +processed roughly 2,200–2,500 events per second when its checkpoints and +verification were included. This is below the scenario's +5,000-events-per-second burst and is a prototype limitation, not a general Fold +throughput result. + +### Crash while a write transaction is open + +The parent created and checkpointed one event. A child process began a +100-event transaction and exited with code 72 after its fiftieth insert, +before the transaction returned. + +After reopen: + +- Retained count: 1 +- Retained event IDs matched the pre-crash set: true +- None of the partial transaction appeared + +### Crash during upload + +The queue held 120 events. A child durably recorded a 25-event upload +intent, wrote 7 event IDs to the mock collector, and exited with code 73 +before acknowledging the queue. + +After reopen: + +- Retained count: 120 +- All 120 retained IDs matched: true +- Reported possible duplicates: 25 +- Recovery and inspection: 4 ms in the final timed runs + +The retry sent all 25 intended events: + +- Collector deliveries: 32 +- Unique events: 25 +- Actual duplicates: 7 +- Retained after durable acknowledgement: 95 + +At-least-once delivery is therefore explained: the intent bounds possible +duplicates at 25, and the mock collector observed the exact 7 already sent +before the crash. + +### Minimal disk-limit failure + +The probe used a 1 MiB logical limit. It first added 8 KiB debug events, +then 8 KiB critical events that evicted debug records. + +- Logical limit: 1,048,576 bytes +- Retained logical bytes: 1,048,512 +- Allocated database bytes from Unix block accounting: 3,305,472 +- Apparent database file bytes: 67,123,220 +- Strict allocated-byte limit satisfied: false + +This is the minimal acceptance failure. It does not prove what the exact +overage would be at 256 MiB. It proves that the prototype's logical control +loop cannot enforce a hard allocated-byte cap through Fold's public interface. +External filesystem enforcement was not evaluated. + +### Memory + +I sampled the final demonstration process every 50 ms with `ps`. + +- Simulator maximum sampled resident memory: 19,392 KiB +- Reviewer maximum sampled resident memory: 19,456 KiB +- Limit in the brief: 65,536 KiB + +This is evidence only for the one-million-event streaming baseline model +plus the 20,000-event real candidate run. It is not a million-event Fold +memory claim, and 50 ms sampling can miss a short peak. + +## Acceptance audit + +| Acceptance item | Result | Evidence boundary | +| --- | --- | --- | +| Deterministic one-million-event workload | Partial | The baseline model processes exactly one million events. The Fold candidate was not scaled to one million after the disk-cap prerequisite failed. | +| Stay within 256 MiB disk and 64 MiB memory | No fit / partial | The 1 MiB probe shows that the public interface's logical accounting is not a hard allocated-byte guarantee. Exact 256 MiB behavior and external quotas were not tested. Sampled host RSS was 19,392–19,456 KiB and may miss short peaks. | +| Preserve critical ahead of lower priority | Pass, representative | All 600 critical events were retained in the 20,000-event run; only debug events dropped. | +| Recover every retained event after crashes | Pass, representative | Exact ID sets matched after the interrupted write and interrupted upload cases. | +| Report possible duplicates | Pass, representative | Intent reported 25 possible; collector observed 7 actual duplicates. | +| Dropped counts by priority/category/reason | Pass, representative | Durable counters reported 1,024 debug/diagnostics evictions and 5,657 incoming debug/diagnostics drops. | +| Recovery within two seconds | Pass, representative | Reopen plus exact-ID and intent inspection took 4 ms. | +| One CPU core | Unresolved | The executable is single-writer, but I did not measure or constrain internal database background threads. | +| Burst to 5,000 events per second | Unresolved | The candidate stage demonstrated roughly 2,200–2,500 events per second with prototype checkpoint and verification work. No sustained production ingest test was run. | + +## Ordered discovery and friction trail + +1. I confirmed the assigned checkout and listed only its root README, + examples, and Cargo manifests. +2. I read the public root `README.md` first. It described Fold as a + persistent incremental framework and recommended the project generator. +3. I read the root workspace manifest and the `starter`, `timeseries`, + `chat`, and `search` examples in that order. The starter example made the + atomic write and persistent Bag pattern clear. The other examples were + useful context but not required for this problem. +4. I did not run `scripts/new-project.sh`. Its README description says it + adds `fold`, `anny`, and `ese`; this prototype needs only Fold. I created + a standalone crate with a local Fold path instead. +5. Before selecting Fold, I specified and implemented the age-only + newline-file baseline. The one-million-event result demonstrated + priority-blind critical loss and whole-file duplicate exposure. +6. I then inspected Fold's public module documentation and the Stream, Bag, + Table, Aggregate, and Retain implementations. Stream transactions, Bag + ordering, and Aggregate views fit the atomic accounting problem. + Retain did not fit because it is time-based rather than byte- and + priority-based. +7. The first `cargo check` could not resolve `index.crates.io`. I retried + with network access and Cargo downloaded the checkout's declared + dependencies. No credential or source-code problem was involved. +8. The first test compile found one method-reference type mismatch in a + test. I replaced it with an explicit closure. +9. The first warnings-denied Clippy run found two manual modulus checks. I + used Rust's `is_multiple_of` method and reran the lint successfully. +10. `/usr/bin/time -l` could not read `kern.clockrate` in the restricted + environment. I used `/usr/bin/time -p` for elapsed time and sampled + resident memory with `ps` every 50 ms. +11. My first directory-size helper summed apparent file lengths. A `du` + cross-check showed that sparse/preallocated files made that an + unsuitable physical-disk measure. I changed the probe to use Unix + allocated block counts. Both values remain reported, with the quota + decision based on allocated bytes. + +## Exact validation commands and results + +From `developer-simulation/`: + +```console +cargo test -p edge-spool-pressure --all-targets +``` + +Result: 10 passed, 0 failed, 0 ignored. + +```console +cargo fmt --check -p edge-spool-pressure +``` + +Result: exit 0 with no formatting differences. + +```console +cargo clippy -p edge-spool-pressure --all-targets -- -D warnings +``` + +Result: exit 0 with no warnings. + +```console +cargo build -p edge-spool-pressure --release +``` + +Result: optimized build completed successfully. + +```console +/usr/bin/time -p target/release/edge-spool-pressure demo +``` + +Result: exit 0. The simulator observed 10.31 seconds and final archive +verification observed 11.95 seconds. The detailed baseline, candidate, crash, +retry, and quota results are recorded above. + +Final memory sampling command: + +```console +target/release/edge-spool-pressure demo & +devsim_pid=$! +devsim_max_rss=0 +while kill -0 $devsim_pid 2>/dev/null; do + devsim_rss=$(ps -o rss= -p $devsim_pid | tr -d ' ') + if [[ -n $devsim_rss && $devsim_rss -gt $devsim_max_rss ]]; then + devsim_max_rss=$devsim_rss + fi + sleep 0.05 +done +wait $devsim_pid +``` + +Result: exit 0; the simulator sampled 19,392 KiB maximum RSS and the reviewer +sampled 19,456 KiB. The 50 ms interval can miss short peaks. + +## Categorized findings + +### Baseline correctness defect: age-only deletion loses critical events + +- Severity: high +- Confidence: high +- Scope: defect in the supplied newline-file baseline, not in BogKit +- Evidence: The deterministic baseline deleted 6,894 critical events, + including 4,596 security and 2,298 hardware-failure events. +- Reproduction: Run the `baseline` command or the full demo. +- Smallest plausible improvement: Separate files or byte budgets by + priority, and record every eviction with priority, category, and reason. + +### Baseline correctness defect: whole-file retry has a large explanation gap + +- Severity: high +- Confidence: high +- Scope: defect in the supplied newline-file baseline, not in BogKit +- Evidence: The modeled first retained file contains 12,064 events. A + disconnect after any prefix followed by whole-file retry exposes that prefix + to duplication, while the baseline has no durable request intent or offset. +- Reproduction: Run the `baseline` command. +- Smallest plausible improvement: Durably record the exact attempted batch + before sending, then clear it atomically with retained-event + acknowledgement. + +### Prototype limitation: existing ranked range scans were not evaluated + +- Severity: informational +- Confidence: high that the trial left an alternative untested; low that Fold + itself causes the observed throughput +- Evidence: The prototype uses a full Bag iterator for priority eviction and + its candidate stage processed roughly 2,200–2,500 events per second. Fold + already exposes `Ranked` range scans over tuple scores, but the trial did not + try that API. +- Reproduction: Run the 20,000-event demo and inspect the eviction path. +- Smallest plausible improvement: Evaluate the existing `Ranked` API before + proposing any range-scan or eviction API. + +### API friction: removal requires reproducing the full event + +- Severity: informational +- Confidence: medium +- Evidence: `Stream::remove` needs the exact original value. The upload + intent stores full event copies so acknowledgement can retract them. + `KeyedStream` removes by key, but its primary table does not have a public + iterator; adding a terminal Table would duplicate payload storage. +- Reproduction: Inspect `acknowledge_upload` and compare it with Fold's + `Stream` and `KeyedStream` APIs. +- Smallest plausible improvement: None from this one trial. Evaluate + `KeyedStream` and bounded intent representations before proposing an API. + +### Documentation gap: no edge-storage suitability boundary + +- Severity: medium +- Confidence: high +- Evidence: The root README explains persistence and atomic writes, but it + does not discuss allocated disk growth, compaction, database minimums, + hard quotas, write amplification, or memory/thread bounds. I had to read + implementation files and build the quota probe. +- Reproduction: Start from the root README and examples as instructed. +- Smallest plausible improvement: Add a “storage limits and durability” + section with hard-limit non-goals, allocated-vs-logical sizing, process + crash vs power-loss guarantees, and operational sizing guidance. + +### Missing scenario capability: hard allocated-byte quota + +- Severity: critical +- Confidence: high that the public interface has no documented hard-cap + guarantee; medium for behavior at exactly 256 MiB because that scale was not + run +- Evidence: A 1 MiB logical limit produced 3,305,472 allocated bytes. Fold + exposes checkpointing but no public allocated-byte quota or compaction + control. +- Reproduction: Run `quota-probe` in a new directory. +- Smallest plausible improvement: Document hard allocated-byte limits as an + unsupported boundary. Consider admission control, compaction reserve, and + allocated-byte telemetry only if strict quotas are an intended use case. + +### Poor product fit: strict bounded edge spool + +- Severity: critical +- Confidence: high +- Evidence: The candidate passes representative transaction, recovery, + priority, duplicate, and accounting checks but fails the disk-bound + prerequisite. +- Reproduction: Run the full demo. +- Smallest plausible improvement: Use a storage engine designed for a hard + ring/segment budget, while retaining Fold only for small derived counters + if its own database allocation is separately bounded. + +## Decision audit + +### Consequential choices + +- I evaluated the newline-file baseline before selecting a candidate. +- I used `Stream` plus Bag rather than KeyedStream. This avoids storing every + payload in both the KeyedStream root and a separately iterable Table. +- I put the priority rank first in the serialized Event so the documented + Bag order provides critical-first upload without an in-memory sort. +- I made eviction and drop accounting one Fold transaction. +- I made upload intent durable before collector delivery and cleared it in + the same transaction as retained-event removal. +- I kept upload batches small, so storing full events in the intent stays + bounded in this prototype. +- I based the no-fit decision on allocated Unix blocks, not apparent file + lengths. + +### Rejected alternatives + +- `Retain`: It expires by elapsed time and cannot express byte pressure or + priority preservation. +- `TopK`: It is count-based, not byte-based, and does not provide the + required durable drop reason accounting. +- `KeyedStream` plus terminal Table: It enables removal by ID but duplicates + payloads and still does not provide a hard disk cap. +- Direct use of Fold's internal Fjall store: It would bypass the public + BogKit interface and still require designing compaction headroom and + quota guarantees. +- Exactly-once delivery: Explicitly outside scope and unnecessary; durable + intent makes at-least-once behavior explainable. +- Scaling the Fold candidate to one million events: Rejected after the + minimal disk-limit prerequisite failed. Doing so would add runtime and + disk cost without changing the fit decision. +- `Ranked` range scans: Not evaluated. This is why the review rejected the + trial's proposed range-scan API and performance attribution. + +### Unresolved uncertainty + +- Exact allocated-byte behavior at a 256 MiB logical limit. +- Sustained ingest at 5,000 events per second. +- One-million-event Fold recovery time and memory. +- Behavior under power loss rather than process termination. +- Behavior when the operating system terminates the process during the + storage engine's own commit sequence. +- Internal database background-thread CPU use under compaction. +- Long-running allocated space after multiple compaction cycles. +- Enforcement through an external filesystem quota. +- A real HTTP client's buffering and disconnect behavior; the mock + collector persists event IDs locally. +- Cross-version stability of a schema that deliberately relies on postcard + field ordering. + +These uncertainties do not overturn the no-fit decision because the required +hard-cap guarantee is absent from the public interface the trial evaluated. diff --git a/developer-simulation/runs/2026-07-29--edge-spool-pressure/README.md b/developer-simulation/runs/2026-07-29--edge-spool-pressure/README.md new file mode 100644 index 0000000..18054ea --- /dev/null +++ b/developer-simulation/runs/2026-07-29--edge-spool-pressure/README.md @@ -0,0 +1,46 @@ +# Edge spool pressure prototype + +This local executable compares: + +1. A streaming model of the current age-based newline-file queue. +2. A small Fold-backed priority spool. + +The candidate intentionally stops short of claiming production fit. Fold +provides useful atomic, crash-safe state, but its public interface does not +provide a hard allocated-disk quota guarantee. The `quota-probe` command +demonstrates why logical accounting alone cannot enforce that requirement. + +Run from `developer-simulation/`. + +Run the tests and strict lint: + +```console +cargo test -p edge-spool-pressure --all-targets +cargo clippy -p edge-spool-pressure --all-targets -- -D warnings +``` + +Run all representative checks: + +```console +cargo run -p edge-spool-pressure --release -- demo +``` + +Run only the deterministic one-million-event baseline model: + +```console +cargo run -p edge-spool-pressure --release -- baseline +``` + +Run a quota probe in a new directory: + +```console +cargo run -p edge-spool-pressure --release -- quota-probe /tmp/bogkit-quota-probe +``` + +The demo creates a unique directory under the operating system temporary +directory and prints the path. It does not delete prior runs. + +The 1 MiB probe shows that retained logical bytes can stay under their limit +while allocated database bytes exceed it. It does not predict exact allocation +at 256 MiB or evaluate an external filesystem quota. See +[`EVIDENCE.md`](EVIDENCE.md) for the bounded decision audit. diff --git a/developer-simulation/runs/2026-07-29--edge-spool-pressure/src/main.rs b/developer-simulation/runs/2026-07-29--edge-spool-pressure/src/main.rs new file mode 100644 index 0000000..18d171e --- /dev/null +++ b/developer-simulation/runs/2026-07-29--edge-spool-pressure/src/main.rs @@ -0,0 +1,1154 @@ +use std::collections::{BTreeMap, HashSet, VecDeque}; +use std::env; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use fold::pipeline::terminal::{self, Bag, Table}; +use fold::pipeline::{Aggregate, FilterMap, KeyBy}; +use fold::stream::Stream; +use serde::{Deserialize, Serialize}; + +const MIB: u64 = 1024 * 1024; +const BASELINE_CAPACITY: u64 = 256 * MIB; +const BASELINE_FILE_SIZE: u64 = 4 * MIB; +const MILLION: u64 = 1_000_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[repr(u8)] +enum Priority { + Critical = 0, + Operational = 1, + Debug = 2, +} + +impl Priority { + fn name(self) -> &'static str { + match self { + Self::Critical => "critical", + Self::Operational => "operational", + Self::Debug => "debug", + } + } + + fn from_rank(rank: u8) -> Self { + match rank { + 0 => Self::Critical, + 1 => Self::Operational, + _ => Self::Debug, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[repr(u8)] +enum Category { + Security = 0, + HardwareFailure = 1, + Operations = 2, + Diagnostics = 3, +} + +impl Category { + fn name(self) -> &'static str { + match self { + Self::Security => "security", + Self::HardwareFailure => "hardware_failure", + Self::Operations => "operations", + Self::Diagnostics => "diagnostics", + } + } + + fn from_rank(rank: u8) -> Self { + match rank { + 0 => Self::Security, + 1 => Self::HardwareFailure, + 2 => Self::Operations, + _ => Self::Diagnostics, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[repr(u8)] +enum DropReason { + IncomingLowerPriority = 0, + EvictedForHigherPriority = 1, + EventExceedsLogicalQuota = 2, +} + +impl DropReason { + fn name(self) -> &'static str { + match self { + Self::IncomingLowerPriority => "incoming_lower_priority", + Self::EvictedForHigherPriority => "evicted_for_higher_priority", + Self::EventExceedsLogicalQuota => "event_exceeds_logical_quota", + } + } + + fn from_rank(rank: u8) -> Self { + match rank { + 0 => Self::IncomingLowerPriority, + 1 => Self::EvictedForHigherPriority, + _ => Self::EventExceedsLogicalQuota, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct Event { + // This field is first so Bag's documented postcard ordering yields + // critical, then operational, then debug records. + priority_rank: u8, + id_be: [u8; 8], + gateway_id: [u8; 8], + timestamp_ms_be: [u8; 8], + category_rank: u8, + accounted_bytes: u32, + payload: Vec, +} + +impl Event { + fn id(&self) -> u64 { + u64::from_be_bytes(self.id_be) + } + + fn priority(&self) -> Priority { + Priority::from_rank(self.priority_rank) + } + + fn category(&self) -> Category { + Category::from_rank(self.category_rank) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct UploadIntent { + attempt: u64, + events: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +struct QueueKey { + priority_rank: u8, + category_rank: u8, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +struct QueueStats { + count: i64, + bytes: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct DropFact { + priority_rank: u8, + category_rank: u8, + reason_rank: u8, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +struct DropKey { + priority_rank: u8, + category_rank: u8, + reason_rank: u8, +} + +#[derive(Debug, Clone)] +enum Mutation { + Queue(Event), + Drop(DropFact), + Upload(UploadIntent), +} + +fn mutation_event(value: &Mutation) -> Option { + match value { + Mutation::Queue(event) => Some(event.clone()), + _ => None, + } +} + +fn mutation_drop(value: &Mutation) -> Option { + match value { + Mutation::Drop(drop) => Some(*drop), + _ => None, + } +} + +fn mutation_upload(value: &Mutation) -> Option { + match value { + Mutation::Upload(intent) => Some(intent.clone()), + _ => None, + } +} + +fn queue_key(event: &Event) -> QueueKey { + QueueKey { + priority_rank: event.priority_rank, + category_rank: event.category_rank, + } +} + +fn queue_step(stats: &mut QueueStats, event: &Event, delta: isize) { + stats.count += delta as i64; + stats.bytes += i64::from(event.accounted_bytes) * delta as i64; +} + +fn drop_key(drop: &DropFact) -> DropKey { + DropKey { + priority_rank: drop.priority_rank, + category_rank: drop.category_rank, + reason_rank: drop.reason_rank, + } +} + +fn drop_step(count: &mut i64, _drop: &DropFact, delta: isize) { + *count += delta as i64; +} + +type EventBranch = FilterMap Option, Bag, Mutation, Event>; +type QueueAggregate = Aggregate< + QueueKey, + Event, + QueueStats, + fn(&mut QueueStats, &Event, isize), + Table, +>; +type QueueBranch = FilterMap< + fn(&Mutation) -> Option, + KeyBy QueueKey, QueueAggregate, QueueKey, Event>, + Mutation, + Event, +>; +type DropAggregate = + Aggregate>; +type DropBranch = FilterMap< + fn(&Mutation) -> Option, + KeyBy DropKey, DropAggregate, DropKey, DropFact>, + Mutation, + DropFact, +>; +type IntentBranch = + FilterMap Option, Bag, Mutation, UploadIntent>; +type SpoolPipeline = (EventBranch, QueueBranch, DropBranch, IntentBranch); + +fn pipeline() -> SpoolPipeline { + ( + FilterMap::new( + mutation_event as fn(&Mutation) -> Option, + terminal::Bag::new("queued_events"), + ), + FilterMap::new( + mutation_event as fn(&Mutation) -> Option, + KeyBy::new( + queue_key as fn(&Event) -> QueueKey, + Aggregate::new( + "queue_stats_aggregate", + queue_step as fn(&mut QueueStats, &Event, isize), + terminal::Table::new("queue_stats"), + ), + ), + ), + FilterMap::new( + mutation_drop as fn(&Mutation) -> Option, + KeyBy::new( + drop_key as fn(&DropFact) -> DropKey, + Aggregate::new( + "drop_counts_aggregate", + drop_step as fn(&mut i64, &DropFact, isize), + terminal::Table::new("drop_counts"), + ), + ), + ), + FilterMap::new( + mutation_upload as fn(&Mutation) -> Option, + terminal::Bag::new("upload_intents"), + ), + ) +} + +struct Spool { + stream: Stream, + logical_quota: u64, +} + +#[derive(Debug, Default)] +struct SpoolSnapshot { + retained_count: u64, + logical_bytes: u64, + retained_by_priority: BTreeMap<&'static str, u64>, + drops: BTreeMap, + intent: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EnqueueOutcome { + Retained, + Dropped, +} + +impl Spool { + fn open(path: &Path, logical_quota: u64) -> Self { + Self { + stream: Stream::new(path, pipeline()), + logical_quota, + } + } + + fn checkpoint(&mut self) { + self.stream.checkpoint(); + } + + fn snapshot(&self) -> SpoolSnapshot { + self.stream.rtx(|(_events, queue_stats, drops, intents)| { + let mut snapshot = SpoolSnapshot::default(); + for (key, stats) in queue_stats.iter() { + let count = u64::try_from(stats.count).expect("negative queue count"); + let bytes = u64::try_from(stats.bytes).expect("negative queue bytes"); + snapshot.retained_count += count; + snapshot.logical_bytes += bytes; + *snapshot + .retained_by_priority + .entry(Priority::from_rank(key.priority_rank).name()) + .or_default() += count; + } + for (key, count) in drops.iter() { + let label = format!( + "{}/{}/{}", + Priority::from_rank(key.priority_rank).name(), + Category::from_rank(key.category_rank).name(), + DropReason::from_rank(key.reason_rank).name() + ); + snapshot + .drops + .insert(label, u64::try_from(count).expect("negative drop count")); + } + snapshot.intent = intents.iter().next().map(|(intent, _)| intent); + + snapshot + }) + } + + fn verify_consistency(&self) { + let expected = self.snapshot().retained_count; + let bag_count = self.stream.rtx(|(events, _, _, _)| { + events + .iter() + .map(|(_, multiplicity)| { + u64::try_from(multiplicity).expect("negative multiplicity") + }) + .sum::() + }); + assert_eq!( + bag_count, expected, + "event Bag and aggregate count diverged" + ); + } + + fn enqueue(&mut self, event: Event) -> EnqueueOutcome { + let snapshot = self.snapshot(); + let event_bytes = u64::from(event.accounted_bytes); + + if event_bytes > self.logical_quota { + self.record_drop(&event, DropReason::EventExceedsLogicalQuota); + return EnqueueOutcome::Dropped; + } + + if snapshot.logical_bytes + event_bytes <= self.logical_quota { + self.stream + .wtx(|tx| tx.insert(&Mutation::Queue(event.clone()))); + return EnqueueOutcome::Retained; + } + + let needed = snapshot.logical_bytes + event_bytes - self.logical_quota; + let eviction_ranks: &[u8] = match event.priority() { + Priority::Critical => &[Priority::Debug as u8, Priority::Operational as u8], + Priority::Operational => &[Priority::Debug as u8], + Priority::Debug => &[], + }; + + let evictions = self.stream.rtx(|(events, _, _, _)| { + let mut chosen = Vec::new(); + let mut reclaimed = 0_u64; + for wanted_rank in eviction_ranks { + for (candidate, multiplicity) in events.iter() { + if candidate.priority_rank != *wanted_rank { + continue; + } + for _ in 0..multiplicity { + reclaimed += u64::from(candidate.accounted_bytes); + chosen.push(candidate.clone()); + if reclaimed >= needed { + return chosen; + } + } + } + } + chosen + }); + + let reclaimed: u64 = evictions + .iter() + .map(|candidate| u64::from(candidate.accounted_bytes)) + .sum(); + if reclaimed < needed { + self.record_drop(&event, DropReason::IncomingLowerPriority); + return EnqueueOutcome::Dropped; + } + + self.stream.wtx(|tx| { + for candidate in &evictions { + tx.remove(&Mutation::Queue(candidate.clone())); + tx.insert(&Mutation::Drop(DropFact { + priority_rank: candidate.priority_rank, + category_rank: candidate.category_rank, + reason_rank: DropReason::EvictedForHigherPriority as u8, + })); + } + tx.insert(&Mutation::Queue(event)); + }); + EnqueueOutcome::Retained + } + + fn record_drop(&mut self, event: &Event, reason: DropReason) { + self.stream.wtx(|tx| { + tx.insert(&Mutation::Drop(DropFact { + priority_rank: event.priority_rank, + category_rank: event.category_rank, + reason_rank: reason as u8, + })) + }); + } + + fn ordered_events(&self, limit: usize) -> Vec { + self.stream.rtx(|(events, _, _, _)| { + let mut selected = Vec::with_capacity(limit); + for (event, multiplicity) in events.iter() { + for _ in 0..multiplicity { + selected.push(event.clone()); + if selected.len() == limit { + return selected; + } + } + } + selected + }) + } + + fn prepare_upload(&mut self, limit: usize, attempt: u64) -> Option { + if let Some(intent) = self.snapshot().intent { + return Some(intent); + } + let events = self.ordered_events(limit); + if events.is_empty() { + return None; + } + let intent = UploadIntent { attempt, events }; + self.stream + .wtx(|tx| tx.insert(&Mutation::Upload(intent.clone()))); + Some(intent) + } + + fn acknowledge_upload(&mut self, intent: &UploadIntent) { + self.stream.wtx(|tx| { + for event in &intent.events { + tx.remove(&Mutation::Queue(event.clone())); + } + tx.remove(&Mutation::Upload(intent.clone())); + }); + } +} + +#[derive(Debug, Clone, Copy, Default)] +struct BaselineBucket { + debug: u64, + operational: u64, + critical: u64, + security: u64, + hardware_failure: u64, +} + +impl BaselineBucket { + fn add(&mut self, event: &Event) { + match event.priority() { + Priority::Debug => self.debug += 1, + Priority::Operational => self.operational += 1, + Priority::Critical => self.critical += 1, + } + match event.category() { + Category::Security => self.security += 1, + Category::HardwareFailure => self.hardware_failure += 1, + _ => {} + } + } + + fn merge(&mut self, other: Self) { + self.debug += other.debug; + self.operational += other.operational; + self.critical += other.critical; + self.security += other.security; + self.hardware_failure += other.hardware_failure; + } + + fn total(self) -> u64 { + self.debug + self.operational + self.critical + } +} + +#[derive(Debug, Default)] +struct FileSummary { + bytes: u64, + events: BaselineBucket, +} + +#[derive(Debug)] +struct BaselineResult { + generated: BaselineBucket, + retained: BaselineBucket, + dropped_oldest: BaselineBucket, + retained_bytes: u64, + files: usize, + possible_duplicates_after_mid_file_disconnect: u64, +} + +fn simulate_baseline(count: u64) -> BaselineResult { + let mut files = VecDeque::new(); + let mut current = FileSummary::default(); + let mut generated = BaselineBucket::default(); + let mut dropped_oldest = BaselineBucket::default(); + let mut retained_bytes = 0_u64; + + for sequence in 0..count { + let event = synthetic_event(sequence); + let line_bytes = u64::from(event.accounted_bytes) + 32; + if current.bytes > 0 && current.bytes + line_bytes > BASELINE_FILE_SIZE { + retained_bytes += current.bytes; + files.push_back(current); + current = FileSummary::default(); + } + current.bytes += line_bytes; + current.events.add(&event); + generated.add(&event); + + while retained_bytes + current.bytes > BASELINE_CAPACITY { + let Some(deleted) = files.pop_front() else { + break; + }; + retained_bytes -= deleted.bytes; + dropped_oldest.merge(deleted.events); + } + } + + if current.bytes > 0 { + retained_bytes += current.bytes; + files.push_back(current); + } + while retained_bytes > BASELINE_CAPACITY { + let deleted = files + .pop_front() + .expect("baseline queue unexpectedly empty"); + retained_bytes -= deleted.bytes; + dropped_oldest.merge(deleted.events); + } + + let mut retained = BaselineBucket::default(); + for file in &files { + retained.merge(file.events); + } + let possible_duplicates_after_mid_file_disconnect = + files.front().map_or(0, |file| file.events.total()); + + BaselineResult { + generated, + retained, + dropped_oldest, + retained_bytes, + files: files.len(), + possible_duplicates_after_mid_file_disconnect, + } +} + +fn splitmix64(mut value: u64) -> u64 { + value = value.wrapping_add(0x9e37_79b9_7f4a_7c15); + value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + value ^ (value >> 31) +} + +fn synthetic_event(sequence: u64) -> Event { + let percentile = sequence % 100; + let (priority, category) = if percentile < 3 { + let category = if sequence.is_multiple_of(2) { + Category::Security + } else { + Category::HardwareFailure + }; + (Priority::Critical, category) + } else if percentile < 15 { + (Priority::Operational, Category::Operations) + } else { + (Priority::Debug, Category::Diagnostics) + }; + let payload_len = if sequence.is_multiple_of(10_000) { + 8 * 1024 + } else { + 100 + usize::try_from(splitmix64(sequence) % 301).expect("payload size") + }; + let prefix = format!("{{\"event_id\":{sequence},\"data\":\""); + let suffix = "\"}"; + let filler_len = payload_len.saturating_sub(prefix.len() + suffix.len()); + let mut payload = Vec::with_capacity(prefix.len() + filler_len + suffix.len()); + payload.extend_from_slice(prefix.as_bytes()); + payload.extend(std::iter::repeat_n(b'x', filler_len)); + payload.extend_from_slice(suffix.as_bytes()); + + Event { + priority_rank: priority as u8, + id_be: sequence.to_be_bytes(), + gateway_id: 7_u64.to_be_bytes(), + timestamp_ms_be: (1_700_000_000_000_u64 + sequence).to_be_bytes(), + category_rank: category as u8, + accounted_bytes: u32::try_from(payload.len() + 64).expect("event too large"), + payload, + } +} + +fn append_collector(path: &Path, events: &[Event]) -> std::io::Result<()> { + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + for event in events { + writeln!(file, "{}", event.id())?; + } + file.sync_all() +} + +#[derive(Debug, Default)] +struct CollectorSummary { + deliveries: u64, + unique: u64, + duplicates: u64, +} + +fn collector_summary(path: &Path) -> std::io::Result { + let file = File::open(path)?; + let mut seen = HashSet::new(); + let mut deliveries = 0_u64; + let mut duplicates = 0_u64; + for line in BufReader::new(file).lines() { + let id: u64 = line?.parse().expect("collector id is numeric"); + deliveries += 1; + if !seen.insert(id) { + duplicates += 1; + } + } + Ok(CollectorSummary { + deliveries, + unique: u64::try_from(seen.len()).expect("collector length"), + duplicates, + }) +} + +#[derive(Debug, Default)] +struct DirectoryUsage { + apparent_bytes: u64, + allocated_bytes: u64, +} + +fn directory_usage(path: &Path) -> std::io::Result { + fn visit(path: &Path, usage: &mut DirectoryUsage) -> std::io::Result<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + let metadata = entry.metadata()?; + if metadata.is_dir() { + visit(&entry.path(), usage)?; + } else { + usage.apparent_bytes += metadata.len(); + usage.allocated_bytes += metadata.blocks() * 512; + } + } + Ok(()) + } + + let mut usage = DirectoryUsage::default(); + visit(path, &mut usage)?; + Ok(usage) +} + +fn unique_demo_root() -> PathBuf { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock before epoch") + .as_millis(); + env::temp_dir().join(format!("edge-spool-pressure-{}-{now}", std::process::id())) +} + +fn print_baseline(result: &BaselineResult) { + println!( + "baseline: generated={} retained={} bytes={} files={} dropped_oldest={}", + result.generated.total(), + result.retained.total(), + result.retained_bytes, + result.files, + result.dropped_oldest.total() + ); + println!( + "baseline: dropped critical={} security={} hardware_failure={}", + result.dropped_oldest.critical, + result.dropped_oldest.security, + result.dropped_oldest.hardware_failure + ); + println!( + "baseline: modeled whole-file retry exposes up to {} retained events to duplication", + result.possible_duplicates_after_mid_file_disconnect + ); +} + +fn run_demo() -> Result<(), Box> { + let root = unique_demo_root(); + fs::create_dir_all(&root)?; + println!("demo_root={}", root.display()); + + let baseline_started = Instant::now(); + let baseline = simulate_baseline(MILLION); + print_baseline(&baseline); + println!( + "baseline_model_elapsed_ms={}", + baseline_started.elapsed().as_millis() + ); + assert_eq!(baseline.generated.total(), MILLION); + assert!(baseline.dropped_oldest.critical > 0); + assert!(baseline.retained_bytes <= BASELINE_CAPACITY); + + let candidate_path = root.join("candidate"); + let logical_quota = 4 * MIB; + let mut spool = Spool::open(&candidate_path, logical_quota); + let ingest_started = Instant::now(); + for sequence in 0..20_000 { + spool.enqueue(synthetic_event(sequence)); + } + spool.checkpoint(); + spool.verify_consistency(); + let candidate = spool.snapshot(); + let candidate_usage = directory_usage(&candidate_path)?; + println!( + "candidate_representative: generated=20000 retained={} logical_bytes={} allocated_bytes={} apparent_bytes={} elapsed_ms={}", + candidate.retained_count, + candidate.logical_bytes, + candidate_usage.allocated_bytes, + candidate_usage.apparent_bytes, + ingest_started.elapsed().as_millis() + ); + println!( + "candidate_representative: retained_by_priority={:?}", + candidate.retained_by_priority + ); + println!("candidate_representative: drops={:?}", candidate.drops); + assert!(candidate.logical_bytes <= logical_quota); + assert_eq!( + candidate.retained_by_priority.get("critical").copied(), + Some(600) + ); + + let crash_write_path = root.join("crash-write"); + let mut crash_spool = Spool::open(&crash_write_path, MIB); + crash_spool.enqueue(synthetic_event(9_000_000)); + crash_spool.checkpoint(); + drop(crash_spool); + let crash_status = Command::new(env::current_exe()?) + .arg("child-crash-write") + .arg(&crash_write_path) + .status()?; + assert_eq!(crash_status.code(), Some(72)); + let recovered_write_spool = Spool::open(&crash_write_path, MIB); + let recovered_after_write_crash = recovered_write_spool.snapshot(); + let write_ids: Vec<_> = recovered_write_spool + .ordered_events(10) + .iter() + .map(Event::id) + .collect(); + println!( + "write_crash_recovery: child_exit={:?} retained={} ids_match={}", + crash_status.code(), + recovered_after_write_crash.retained_count, + write_ids == [9_000_000] + ); + assert_eq!(recovered_after_write_crash.retained_count, 1); + assert_eq!(write_ids, [9_000_000]); + + let upload_path = root.join("upload-crash"); + let collector_path = root.join("collector.log"); + let mut upload_spool = Spool::open(&upload_path, 8 * MIB); + let mut expected_upload_ids = Vec::new(); + for sequence in 0..120 { + let event = synthetic_event(10_000_000 + sequence); + expected_upload_ids.push(event.id()); + upload_spool.enqueue(event); + } + upload_spool.checkpoint(); + drop(upload_spool); + let upload_crash_status = Command::new(env::current_exe()?) + .arg("child-crash-upload") + .arg(&upload_path) + .arg(&collector_path) + .status()?; + assert_eq!(upload_crash_status.code(), Some(73)); + + let recovery_started = Instant::now(); + let mut recovered_spool = Spool::open(&upload_path, 8 * MIB); + let recovered = recovered_spool.snapshot(); + recovered_spool.verify_consistency(); + let mut recovered_upload_ids: Vec<_> = recovered_spool + .ordered_events(120) + .iter() + .map(Event::id) + .collect(); + expected_upload_ids.sort_unstable(); + recovered_upload_ids.sort_unstable(); + let recovery_elapsed = recovery_started.elapsed(); + let intent = recovered.intent.expect("durable upload intent"); + println!( + "upload_crash_recovery: retained={} ids_match={} possible_duplicates={} recovery_ms={}", + recovered.retained_count, + recovered_upload_ids == expected_upload_ids, + intent.events.len(), + recovery_elapsed.as_millis() + ); + assert_eq!(recovered.retained_count, 120); + assert_eq!(recovered_upload_ids, expected_upload_ids); + assert_eq!(intent.events.len(), 25); + assert!(recovery_elapsed.as_secs_f64() < 2.0); + + append_collector(&collector_path, &intent.events)?; + recovered_spool.acknowledge_upload(&intent); + recovered_spool.checkpoint(); + recovered_spool.verify_consistency(); + let collector = collector_summary(&collector_path)?; + let after_retry = recovered_spool.snapshot(); + println!( + "upload_retry: deliveries={} unique={} actual_duplicates={} retained_after_ack={}", + collector.deliveries, collector.unique, collector.duplicates, after_retry.retained_count + ); + assert_eq!(collector.deliveries, 32); + assert_eq!(collector.unique, 25); + assert_eq!(collector.duplicates, 7); + assert_eq!(after_retry.retained_count, 95); + + let quota_path = root.join("quota-probe"); + let probe = run_quota_probe("a_path)?; + println!( + "quota_probe: logical_quota={} logical_bytes={} allocated_bytes={} apparent_bytes={} strict_quota_satisfied={}", + probe.logical_quota, + probe.logical_bytes, + probe.allocated_bytes, + probe.apparent_bytes, + probe.allocated_bytes <= probe.logical_quota + ); + assert!(probe.logical_bytes <= probe.logical_quota); + assert!( + probe.allocated_bytes > probe.logical_quota, + "probe did not reproduce physical quota gap" + ); + + println!("decision=NO_FIT_FOR_STRICT_256_MIB_BOUND"); + println!( + "reason=Fold's public interface has no documented hard allocated-byte quota guarantee" + ); + Ok(()) +} + +#[derive(Debug)] +struct QuotaProbe { + logical_quota: u64, + logical_bytes: u64, + allocated_bytes: u64, + apparent_bytes: u64, +} + +fn run_quota_probe(path: &Path) -> Result> { + let logical_quota = MIB; + let mut spool = Spool::open(path, logical_quota); + + for sequence in 0..180 { + let mut event = synthetic_event(20_000_000 + sequence); + event.priority_rank = Priority::Debug as u8; + event.category_rank = Category::Diagnostics as u8; + event.payload.resize(8 * 1024, b'd'); + event.accounted_bytes = u32::try_from(event.payload.len() + 64)?; + spool.enqueue(event); + } + for sequence in 0..180 { + let mut event = synthetic_event(30_000_000 + sequence); + event.priority_rank = Priority::Critical as u8; + event.category_rank = Category::Security as u8; + event.payload.resize(8 * 1024, b'c'); + event.accounted_bytes = u32::try_from(event.payload.len() + 64)?; + spool.enqueue(event); + } + spool.checkpoint(); + spool.verify_consistency(); + let logical_bytes = spool.snapshot().logical_bytes; + drop(spool); + let usage = directory_usage(path)?; + Ok(QuotaProbe { + logical_quota, + logical_bytes, + allocated_bytes: usage.allocated_bytes, + apparent_bytes: usage.apparent_bytes, + }) +} + +fn child_crash_write(path: &Path) -> ! { + let mut spool = Spool::open(path, MIB); + spool.stream.wtx(|tx| { + for sequence in 0..100 { + tx.insert(&Mutation::Queue(synthetic_event(40_000_000 + sequence))); + if sequence == 49 { + std::process::exit(72); + } + } + }); + std::process::exit(74); +} + +fn child_crash_upload(path: &Path, collector_path: &Path) -> ! { + let mut spool = Spool::open(path, 8 * MIB); + let intent = spool + .prepare_upload(25, 1) + .expect("events available for upload"); + spool.checkpoint(); + append_collector(collector_path, &intent.events[..7]).expect("collector append"); + std::process::exit(73); +} + +fn usage() { + eprintln!("usage: edge-spool-pressure [demo|baseline|quota-probe ]"); +} + +fn main() -> ExitCode { + let args: Vec = env::args().collect(); + let result = match args.get(1).map(String::as_str).unwrap_or("demo") { + "demo" => run_demo(), + "baseline" => { + let started = Instant::now(); + let result = simulate_baseline(MILLION); + print_baseline(&result); + println!( + "baseline_model_elapsed_ms={}", + started.elapsed().as_millis() + ); + Ok(()) + } + "quota-probe" => match args.get(2) { + Some(path) => run_quota_probe(Path::new(path)).map(|probe| { + println!( + "logical_quota={} logical_bytes={} allocated_bytes={} apparent_bytes={} strict_quota_satisfied={}", + probe.logical_quota, + probe.logical_bytes, + probe.allocated_bytes, + probe.apparent_bytes, + probe.allocated_bytes <= probe.logical_quota + ); + }), + None => { + usage(); + return ExitCode::from(2); + } + }, + "child-crash-write" => match args.get(2) { + Some(path) => child_crash_write(Path::new(path)), + None => { + usage(); + return ExitCode::from(2); + } + }, + "child-crash-upload" => match (args.get(2), args.get(3)) { + (Some(path), Some(collector)) => { + child_crash_upload(Path::new(path), Path::new(collector)) + } + _ => { + usage(); + return ExitCode::from(2); + } + }, + _ => { + usage(); + return ExitCode::from(2); + } + }; + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn test_path(name: &str) -> PathBuf { + unique_demo_root().join(name) + } + + #[test] + fn million_event_baseline_is_deterministic_and_loses_critical_events() { + let first = simulate_baseline(MILLION); + let second = simulate_baseline(MILLION); + assert_eq!(first.generated.total(), MILLION); + assert_eq!(first.retained.total(), second.retained.total()); + assert_eq!( + first.dropped_oldest.critical, + second.dropped_oldest.critical + ); + assert!(first.retained_bytes <= BASELINE_CAPACITY); + assert!(first.dropped_oldest.critical > 0); + assert!(first.dropped_oldest.security > 0); + assert!(first.dropped_oldest.hardware_failure > 0); + } + + #[test] + fn bag_order_prioritizes_critical_then_operational_then_debug() { + let path = test_path("ordering"); + let mut spool = Spool::open(&path, MIB); + spool.enqueue(synthetic_event(99)); + spool.enqueue(synthetic_event(4)); + spool.enqueue(synthetic_event(0)); + let priorities: Vec<_> = spool + .ordered_events(3) + .into_iter() + .map(|event| event.priority()) + .collect(); + assert_eq!( + priorities, + vec![Priority::Critical, Priority::Operational, Priority::Debug] + ); + } + + #[test] + fn critical_event_evicts_debug_and_drop_is_accounted() { + let path = test_path("eviction"); + let mut debug = synthetic_event(99); + debug.accounted_bytes = 600; + let mut critical = synthetic_event(0); + critical.accounted_bytes = 600; + let mut spool = Spool::open(&path, 600); + assert_eq!(spool.enqueue(debug), EnqueueOutcome::Retained); + assert_eq!(spool.enqueue(critical), EnqueueOutcome::Retained); + let snapshot = spool.snapshot(); + assert_eq!(snapshot.retained_count, 1); + assert_eq!( + snapshot.retained_by_priority.get("critical").copied(), + Some(1) + ); + assert_eq!( + snapshot + .drops + .get("debug/diagnostics/evicted_for_higher_priority") + .copied(), + Some(1) + ); + } + + #[test] + fn panic_rolls_back_all_fold_views() { + let path = test_path("panic"); + let mut spool = Spool::open(&path, MIB); + spool.enqueue(synthetic_event(1)); + let before = spool.snapshot(); + let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + spool.stream.wtx(|tx| { + tx.insert(&Mutation::Queue(synthetic_event(2))); + tx.insert(&Mutation::Drop(DropFact { + priority_rank: Priority::Debug as u8, + category_rank: Category::Diagnostics as u8, + reason_rank: DropReason::IncomingLowerPriority as u8, + })); + panic!("injected"); + }); + })); + assert!(panic_result.is_err()); + let after = spool.snapshot(); + assert_eq!(after.retained_count, before.retained_count); + assert_eq!(after.logical_bytes, before.logical_bytes); + assert_eq!(after.drops, before.drops); + } + + #[test] + fn upload_intent_and_retained_events_survive_reopen() { + let path = test_path("reopen"); + { + let mut spool = Spool::open(&path, MIB); + for sequence in 0..20 { + spool.enqueue(synthetic_event(sequence)); + } + let intent = spool.prepare_upload(7, 44).expect("upload intent"); + assert_eq!(intent.events.len(), 7); + spool.checkpoint(); + } + let reopened = Spool::open(&path, MIB); + let snapshot = reopened.snapshot(); + assert_eq!(snapshot.retained_count, 20); + let mut ids: Vec<_> = reopened.ordered_events(20).iter().map(Event::id).collect(); + ids.sort_unstable(); + assert_eq!(ids, (0..20).collect::>()); + let intent = snapshot.intent.expect("intent recovered"); + assert_eq!(intent.attempt, 44); + assert_eq!(intent.events.len(), 7); + } + + #[test] + fn generator_mix_is_exact_for_one_million() { + let result = simulate_baseline(MILLION); + assert_eq!(result.generated.critical, 30_000); + assert_eq!(result.generated.operational, 120_000); + assert_eq!(result.generated.debug, 850_000); + } + + #[test] + fn payload_is_valid_json_shape_and_in_range() { + for sequence in [0, 1, 9_999, 10_000, 88_888] { + let event = synthetic_event(sequence); + assert!(event.payload.starts_with(b"{\"event_id\":")); + assert!(event.payload.ends_with(b"\"}")); + assert!((100..=8 * 1024).contains(&event.payload.len())); + } + } + + #[test] + fn collector_reports_actual_duplicates() { + let path = test_path("collector.log"); + fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + let events: Vec<_> = (0..5).map(synthetic_event).collect(); + append_collector(&path, &events[..3]).expect("first request"); + append_collector(&path, &events).expect("retry"); + let summary = collector_summary(&path).expect("collector summary"); + assert_eq!(summary.deliveries, 8); + assert_eq!(summary.unique, 5); + assert_eq!(summary.duplicates, 3); + } + + #[test] + fn logical_quota_probe_exposes_physical_gap() { + let path = test_path("quota"); + let probe = run_quota_probe(&path).expect("quota probe"); + assert!(probe.logical_bytes <= probe.logical_quota); + assert!(probe.allocated_bytes > probe.logical_quota); + } + + #[test] + fn drop_key_labels_round_trip() { + let mut expected = HashMap::new(); + expected.insert(Priority::Critical as u8, "critical"); + expected.insert(Priority::Operational as u8, "operational"); + expected.insert(Priority::Debug as u8, "debug"); + for (rank, label) in expected { + assert_eq!(Priority::from_rank(rank).name(), label); + } + } +} diff --git a/developer-simulation/runs/2026-07-29--flash-config-journal/Cargo.toml b/developer-simulation/runs/2026-07-29--flash-config-journal/Cargo.toml new file mode 100644 index 0000000..8aac266 --- /dev/null +++ b/developer-simulation/runs/2026-07-29--flash-config-journal/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "flash-config-journal" +version = "0.0.0" +edition.workspace = true +publish.workspace = true + +[dependencies] + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "warn" +pedantic = "allow" diff --git a/developer-simulation/runs/2026-07-29--flash-config-journal/EVIDENCE.md b/developer-simulation/runs/2026-07-29--flash-config-journal/EVIDENCE.md new file mode 100644 index 0000000..5ed7561 --- /dev/null +++ b/developer-simulation/runs/2026-07-29--flash-config-journal/EVIDENCE.md @@ -0,0 +1,242 @@ +# Blind BogKit trial: flash-config-journal + +Completed: 2026-07-29 06:18 EDT + +## Outcome + +**BogKit is not a fit for this firmware storage problem.** `fold` provides +transactional application dataflow on a filesystem-backed `fjall` database. It +does not provide a raw NOR interface, fixed recovery bounds, a `no_std` path, or +a fixed memory envelope. Using it would import the exact filesystem and +allocation assumptions that this trial must avoid. + +I kept no BogKit dependency. I built a dependency-free reference journal and +host-side NOR harness in +`runs/2026-07-29--flash-config-journal`. The journal candidate passes the tested +emulator checks at the minimum and maximum record sizes. This is not a +production firmware recommendation: host timing, whole-stack memory, and the +modeled failure behavior still need measurement on the actual controller and +NOR part. + +## Prototype + +The emulated flash is exactly 131,072 bytes: 32 erase blocks of 4,096 bytes. +Every program checks the NOR 1→0 rule. + +Each journal update: + +1. Scans the 32 block starts for valid committed records. +2. Rejects a revision that is not newer than the highest valid revision. +3. Selects the circular run immediately after the active record. A maximum + record uses seven blocks, so it cannot overlap the seven-block active record. +4. Erases only the selected run. +5. Streams the payload in 256-byte chunks while computing CRC-32. +6. Writes a checked 32-byte header. +7. Writes the one-byte commit marker last. + +A boot scan ignores an absent/torn commit, a malformed header, and a payload +with the wrong checksum. It selects the highest valid revision. Because the +replacement run never overlaps the active run, each tested interruption sees +the complete old value until the final commit byte, then the complete new value. + +The CLI uses a file-backed emulator. The exhaustive crash and wear tests use an +in-memory emulator so that each byte boundary can be inspected deterministically. +Neither emulator claims to reproduce page-program, partial-erase, cache, +controller, or timing behavior of real NOR. + +## Acceptance results + +| Requirement | Result | Evidence | +| --- | --- | --- | +| Every write-boundary crash boots old or new, never mixed | Pass in emulator | The audit inspected all 6,177 boundaries of a 2 KiB update and all 53,281 boundaries of a 24 KiB update: 59,458 total. No boundary had no valid configuration or an unexpected revision. The commit boundary was the only boundary that selected the new value. CRC verification confirmed the final payload. | +| Reject corrupt and older revisions | Pass in emulator | A deterministic programmed-bit corruption in revision 42 made boot select intact revision 41. A proposed revision 40 returned `StaleRevision`. The CLI independently corrupted revision 101 and recovered complete revision 100. | +| At most 128 KiB flash | Pass | Both emulators enforce exactly 131,072 addressable bytes. The CLI creates a 131,072-byte image. | +| At most 16 KiB working memory | Unresolved; explicit-buffer design budget only | The core streams through 256-byte buffers and declares a conservative 1,024-byte design budget for its explicit buffers. It performs no heap allocation itself. This is not a measured or mechanically enforced whole-stack bound; it excludes the host emulator, caller-owned reader, compiler stack-frame overhead, and driver state. | +| Scan at most 32 erase blocks | Pass | Boot always reads exactly the 32 block starts. The test reports 32. | +| Boot within 50 ms | Pass only in the host emulators; hardware unresolved | Maximum-record in-memory scans varied from 228.875 µs to 1.563 ms. File-backed CLI scans varied from 247.708 µs to 4.253 ms across simulator, reviewer, and archive runs. These host figures do not establish controller timing. | +| 10,000 updates, erase imbalance at most 10% | Pass for fixed 2 KiB updates in the emulator | The 10,000-update test used minimum-size records and produced per-block erase counts of 312–313: 0.32% max-minus-min over mean. Mixed and maximum-size wear were not tested. | +| Avoid filesystem semantics, uncontrolled allocation, and unpredictable encoded size | Pass for journal core; BogKit rejected | The core depends only on `Read` and raw NOR traits, uses fixed buffers, and requires a declared 2–24 KiB encoded length. The host CLI alone uses a file. CBOR encoding is outside the prototype boundary. | + +## Single-blob baseline + +I evaluated the stated strategy before selecting any candidate. The preserved +test `baseline_single_blob_has_a_boundary_with_no_valid_configuration` writes a +valid revision 1 at block zero, then models power loss after the first byte of +the in-place erase. That byte destroys the only header and boot finds no valid +configuration. A checksum detects the damage but cannot recover the old value. + +This establishes the required failure before introducing the journal. + +## Ordered discovery and friction trail + +1. I read the public root `README.md` first. Its transactional and crash-safe + description initially sounded relevant, although all public examples were + application databases. +2. I read `examples/starter/src/main.rs`. Its smallest example creates a + directory under the host temporary directory and opens `Stream::new` with a + filesystem path. Fold's crate documentation later described Fjall as + “embedded,” meaning an in-process database rather than embedded firmware. +3. I also read the timeseries, search, and chat examples. They demonstrate + durable materialized views, heap-owned strings and vectors, threads, + networking, and host files. None demonstrates bounded raw storage. +4. I first tried to inspect `fold/src/stream.rs`; that path does not exist. I + listed `fold/src` and found the implementation split between + `fold/src/stream/mod.rs`, `unkeyed.rs`, and `keyed.rs`. +5. The source and `fold/Cargo.toml` resolved the fit question. `Stream::new` + opens `fjall::SingleWriterTxDatabase` from a `Path`; `WriteTx` owns a + growable `Vec`; serialization enables postcard `use-std`; checkpointing + calls filesystem persistence. +6. I inspected `scripts/new-project.sh` rather than running it. It would add + `fold`, `anny`, `ese`, and `serde` to every new example even though this + trial needed none. I created a dependency-free crate manually. +7. I wrote and ran the single-blob failure reproducer. It confirmed that an + in-place erase can remove the only valid configuration. +8. I chose a circular whole-record journal rather than adapting `fold`. +9. The first `cargo fmt --check -p flash-config-journal` reported formatting + diffs. Running the formatter resolved them. +10. The first complete test run passed all five tests. +11. The first warnings-denied Clippy run rejected a test assertion whose value + was compile-time constant. I moved the memory limit check to a compile-time + assertion, then reran every check. +12. The final format, lint, test, and CLI runs all passed. + +## Findings + +### Baseline correctness defect — in-place single-blob update loses the only copy + +- **Severity:** Critical +- **Confidence:** High +- **Scope:** Defect in the supplied single-slot baseline, not in BogKit. +- **Evidence:** After a valid revision 1, changing the first header byte to its + torn-erase state makes the boot scan return no configuration. +- **Reproduction:** `cargo test -p flash-config-journal baseline_single_blob_has_a_boundary_with_no_valid_configuration` +- **Smallest plausible improvement:** Keep the active record untouched while + writing and checking a replacement; publish the replacement with a final + one-way commit marker. + +### API friction — the documented scaffold adds every major crate + +- **Severity:** Low generally; Medium under constrained builds +- **Confidence:** High +- **Evidence:** `scripts/new-project.sh` unconditionally adds `anny`, `ese`, + `fold`, and `serde`. The flash journal needs none. +- **Reproduction:** Read `scripts/new-project.sh`. +- **Smallest plausible improvement:** Let the script accept a minimal preset or + ask which components to include. + +### Documentation gap — “embedded” is easy to read as embedded-device support + +- **Severity:** Medium +- **Confidence:** High +- **Evidence:** Fold's crate documentation calls Fjall “embedded,” while public + onboarding does not define the operating-system, filesystem, allocator, or + `std` boundary near its persistence claims. The root README does not claim + embedded-firmware support. +- **Reproduction:** Start with the root README, then compare it with + `fold/Cargo.toml` and `fold/src/stream/unkeyed.rs`. +- **Smallest plausible improvement:** Say “in-process, filesystem-backed + database for `std` targets” and list unsupported firmware constraints. + +### Missing capability — no raw NOR or fixed-memory storage layer + +- **Severity:** Critical for this use case +- **Confidence:** High +- **Evidence:** The public entry point takes a filesystem `Path`. The write + transaction uses `Vec`. No raw read/program/erase trait, `no_std` feature, + erase-block geometry, commit-byte primitive, wear accounting, or fixed + recovery-I/O bound appears in the inspected public surface. +- **Reproduction:** Search `README.md`, `fold`, `examples`, and `scripts` for + `no_std`, `flash`, `NOR`, and allocator guidance; then inspect the stream + types. +- **Smallest plausible improvement:** Add an explicit “not intended for raw + flash or `no_std` firmware” boundary now. A real capability would require a + separate storage engine, not a small adapter. + +### Poor product fit — Fold solves a different state problem + +- **Severity:** Critical if selected; none if rejected +- **Confidence:** High +- **Evidence:** Fold maintains incremental views over data changes in an LSM + store. This controller replaces one bounded opaque blob and needs a + power-fail-safe publication protocol over 32 known erase blocks. +- **Reproduction:** Compare the root README’s Fold description and starter + example with this trial’s acceptance matrix. +- **Smallest plausible improvement:** Add a use-case boundary to the README. + Do not market the transactional API as a substitute for a raw-flash journal. + +## Decision audit + +### Consequential choices + +- **No BogKit dependency.** A host filesystem transaction does not become a NOR + transaction through a thin adapter. +- **Whole-record circular journal.** Updates replace the complete CBOR blob, so + delta materialization would add complexity without saving the required + publication step. +- **Variable contiguous runs.** A record occupies one to seven blocks. Moving + to the run after the active record preserves the active copy and walks wear + around all 32 blocks. +- **Payload first, header second, commit last.** No pre-commit state is + bootable. The commit byte is the only publication boundary. +- **CRC-32 and monotonic `u64` revision.** They match the supplied checksum and + ordering requirements. Signatures are explicitly out of scope. +- **Streaming input.** The core never buffers a 24 KiB configuration. It + requires the caller to know the final encoded length. + +### Rejected alternatives + +- **Existing single slot:** rejected by the preserved torn-erase reproducer. +- **Two fixed slots:** atomic publication is simple, but repeatedly erasing the + same small subset of 32 blocks fails the wear-distribution goal. +- **Fold/fjall:** rejected because it requires filesystem semantics and lacks + raw-device, memory, scan, and wear bounds. +- **Encoding CBOR inside the journal:** rejected because it couples publication + to allocator behavior and encoded-size prediction. The candidate accepts an + already encoded, length-bounded stream. +- **Filesystem crate or extra checksum dependency:** unnecessary for the + smallest reproducer; the emulator and CRC implementation use the standard + library only. + +### Unresolved uncertainty + +- Actual NOR page-program and erase interruption behavior can be less tidy than + the byte model. The hardware driver must define its guarantees. +- The measured host timings do not prove a 50 ms MCU boot. +- The 1,024-byte value is a source-level design budget for explicit core + buffers, not a measured or mechanically enforced bound for the final + compiler's whole stack frame, the input source, or the device driver. +- CRC-32 has collision risk and is not an authenticity check. +- Bad blocks, endurance limits, revision rollover, read-disturb, and post-boot + background faults were not modeled. +- The file emulator calls host sync operations, but host filesystems do not + reproduce NOR persistence. +- A production CBOR encoder must provide a bounded final length or a separate + staging strategy. That encoder was a non-goal here. + +## Verification log + +Final quality and test command: + +```console +cargo fmt -p flash-config-journal && cargo fmt --check -p flash-config-journal && cargo clippy -p flash-config-journal --all-targets -- -D warnings && cargo test -p flash-config-journal --all-targets -- --nocapture && cargo run -p flash-config-journal +``` + +Observed: + +- Formatting check: passed. +- Clippy with warnings denied: passed. +- Tests: 5 passed, 0 failed, completed in 4.43 s. +- Crash boundaries: 6,177 at 2 KiB and 53,281 at 24 KiB. +- Wear: 312–313 erases per block after 10,000 fixed 2 KiB updates, 0.32% + imbalance. +- In-memory maximum-record boot: 32 blocks in 228.875 µs in the simulator run + and 1.563 ms in final archive verification. +- File-backed reopen: revision 101, 24,576 bytes, 32 blocks in 247.708 µs in + the simulator run, 943.917 µs in the reviewer rerun, and 4.253 ms in final + archive verification. +- Deterministic corruption: revision 101 rejected; complete revision 100 + recovered. +- File image: 131,072 bytes. + +No commit, push, GitHub write, automation write, or external repository access +was performed. diff --git a/developer-simulation/runs/2026-07-29--flash-config-journal/README.md b/developer-simulation/runs/2026-07-29--flash-config-journal/README.md new file mode 100644 index 0000000..451dfbb --- /dev/null +++ b/developer-simulation/runs/2026-07-29--flash-config-journal/README.md @@ -0,0 +1,28 @@ +# Flash configuration journal prototype + +This dependency-free Rust CLI tests a power-loss-safe journal for one complete +2–24 KiB configuration blob in 128 KiB of emulated NOR flash. + +The journal writes the payload and checked header into the next circular run of +erase blocks. It writes a one-byte commit marker last. Boot scans exactly 32 +block starts, validates headers and payload CRCs, and selects the highest valid +revision. The previous complete record is never erased while its replacement is +being written. + +Run from `developer-simulation/`: + +```console +cargo test -p flash-config-journal --all-targets +cargo clippy -p flash-config-journal --all-targets -- -D warnings +cargo run -p flash-config-journal +``` + +The CLI writes its 128 KiB file-backed flash image to +`target/flash-config-journal-demo.bin`. The in-memory test emulator checks NOR +1→0 programming, deterministic corruption, every modeled byte boundary of one +minimum- and one maximum-size update, 10,000 fixed 2 KiB updates for wear +balance, scan count, and the explicit-buffer design budget. + +This is a host-side model, not a hardware driver or a claim about physical NOR +timing, whole-stack memory, or failure behavior. See +[`EVIDENCE.md`](EVIDENCE.md) for the bounded decision audit. diff --git a/developer-simulation/runs/2026-07-29--flash-config-journal/src/lib.rs b/developer-simulation/runs/2026-07-29--flash-config-journal/src/lib.rs new file mode 100644 index 0000000..4a5b876 --- /dev/null +++ b/developer-simulation/runs/2026-07-29--flash-config-journal/src/lib.rs @@ -0,0 +1,857 @@ +use std::cell::RefCell; +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; + +pub const FLASH_BYTES: usize = 128 * 1024; +pub const ERASE_BLOCK_BYTES: usize = 4 * 1024; +pub const ERASE_BLOCKS: usize = FLASH_BYTES / ERASE_BLOCK_BYTES; +pub const MIN_CONFIG_BYTES: usize = 2 * 1024; +pub const MAX_CONFIG_BYTES: usize = 24 * 1024; +pub const SCAN_BLOCK_LIMIT: usize = 32; +pub const JOURNAL_WORKING_MEMORY_CEILING_BYTES: usize = 1024; +const _: () = assert!(JOURNAL_WORKING_MEMORY_CEILING_BYTES <= 16 * 1024); + +const MAGIC: [u8; 4] = *b"BJR1"; +const FORMAT_VERSION: u16 = 1; +const HEADER_BYTES: usize = 32; +const COMMIT_OFFSET: usize = HEADER_BYTES; +const PAYLOAD_OFFSET: usize = COMMIT_OFFSET + 1; +const IO_CHUNK_BYTES: usize = 256; +const COMMITTED: u8 = 0x00; + +#[derive(Debug)] +pub enum JournalError { + Io(std::io::Error), + InvalidFlashSize(u64), + OutOfBounds, + NorBitSetAttempt, + InvalidConfigLength(usize), + StaleRevision { current: u64, proposed: u64 }, + SourceLengthMismatch, + Capacity, +} + +impl fmt::Display for JournalError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(f, "I/O error: {error}"), + Self::InvalidFlashSize(size) => { + write!(f, "flash image is {size} bytes; expected {FLASH_BYTES}") + } + Self::OutOfBounds => write!(f, "flash access is out of bounds"), + Self::NorBitSetAttempt => write!(f, "NOR programming attempted a 0-to-1 change"), + Self::InvalidConfigLength(size) => write!( + f, + "configuration is {size} bytes; expected {MIN_CONFIG_BYTES}..={MAX_CONFIG_BYTES}" + ), + Self::StaleRevision { current, proposed } => write!( + f, + "revision {proposed} is not newer than active revision {current}" + ), + Self::SourceLengthMismatch => { + write!( + f, + "configuration source length does not match its declared length" + ) + } + Self::Capacity => write!(f, "journal record would overlap the active record"), + } + } +} + +impl std::error::Error for JournalError {} + +impl From for JournalError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +pub trait NorRead { + fn read(&self, offset: usize, output: &mut [u8]) -> Result<(), JournalError>; +} + +pub trait Nor: NorRead { + fn program(&mut self, offset: usize, data: &[u8]) -> Result<(), JournalError>; + fn erase_block(&mut self, block: usize) -> Result<(), JournalError>; +} + +pub struct FileNor { + file: RefCell, + erase_counts: [u32; ERASE_BLOCKS], +} + +impl FileNor { + pub fn create_fresh(path: impl AsRef) -> Result { + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .read(true) + .write(true) + .open(path)?; + let erased = [0xff; ERASE_BLOCK_BYTES]; + for _ in 0..ERASE_BLOCKS { + file.write_all(&erased)?; + } + file.sync_all()?; + Ok(Self { + file: RefCell::new(file), + erase_counts: [0; ERASE_BLOCKS], + }) + } + + pub fn open(path: impl AsRef) -> Result { + let file = OpenOptions::new().read(true).write(true).open(path)?; + let size = file.metadata()?.len(); + if size != FLASH_BYTES as u64 { + return Err(JournalError::InvalidFlashSize(size)); + } + Ok(Self { + file: RefCell::new(file), + erase_counts: [0; ERASE_BLOCKS], + }) + } + + pub fn erase_counts(&self) -> &[u32; ERASE_BLOCKS] { + &self.erase_counts + } +} + +impl NorRead for FileNor { + fn read(&self, offset: usize, output: &mut [u8]) -> Result<(), JournalError> { + check_range(offset, output.len())?; + let mut file = self.file.borrow_mut(); + file.seek(SeekFrom::Start(offset as u64))?; + file.read_exact(output)?; + Ok(()) + } +} + +impl Nor for FileNor { + fn program(&mut self, offset: usize, data: &[u8]) -> Result<(), JournalError> { + check_range(offset, data.len())?; + let mut old = [0u8; IO_CHUNK_BYTES]; + let mut file = self.file.borrow_mut(); + + for (chunk_index, chunk) in data.chunks(IO_CHUNK_BYTES).enumerate() { + let chunk_offset = offset + chunk_index * IO_CHUNK_BYTES; + file.seek(SeekFrom::Start(chunk_offset as u64))?; + file.read_exact(&mut old[..chunk.len()])?; + if old[..chunk.len()] + .iter() + .zip(chunk) + .any(|(before, after)| before & after != *after) + { + return Err(JournalError::NorBitSetAttempt); + } + } + + for (chunk_index, chunk) in data.chunks(IO_CHUNK_BYTES).enumerate() { + let chunk_offset = offset + chunk_index * IO_CHUNK_BYTES; + file.seek(SeekFrom::Start(chunk_offset as u64))?; + file.write_all(chunk)?; + } + file.sync_data()?; + Ok(()) + } + + fn erase_block(&mut self, block: usize) -> Result<(), JournalError> { + if block >= ERASE_BLOCKS { + return Err(JournalError::OutOfBounds); + } + self.erase_counts[block] += 1; + let erased = [0xff; IO_CHUNK_BYTES]; + let mut file = self.file.borrow_mut(); + file.seek(SeekFrom::Start((block * ERASE_BLOCK_BYTES) as u64))?; + for _ in 0..(ERASE_BLOCK_BYTES / IO_CHUNK_BYTES) { + file.write_all(&erased)?; + } + file.sync_data()?; + Ok(()) + } +} + +#[derive(Clone)] +pub struct MemoryNor { + bytes: Vec, + erase_counts: [u32; ERASE_BLOCKS], +} + +impl MemoryNor { + pub fn new() -> Self { + Self { + bytes: vec![0xff; FLASH_BYTES], + erase_counts: [0; ERASE_BLOCKS], + } + } + + pub fn erase_counts(&self) -> &[u32; ERASE_BLOCKS] { + &self.erase_counts + } +} + +impl Default for MemoryNor { + fn default() -> Self { + Self::new() + } +} + +impl NorRead for MemoryNor { + fn read(&self, offset: usize, output: &mut [u8]) -> Result<(), JournalError> { + check_range(offset, output.len())?; + output.copy_from_slice(&self.bytes[offset..offset + output.len()]); + Ok(()) + } +} + +impl Nor for MemoryNor { + fn program(&mut self, offset: usize, data: &[u8]) -> Result<(), JournalError> { + check_range(offset, data.len())?; + for (before, after) in self.bytes[offset..offset + data.len()].iter_mut().zip(data) { + if *before & after != *after { + return Err(JournalError::NorBitSetAttempt); + } + *before = *after; + } + Ok(()) + } + + fn erase_block(&mut self, block: usize) -> Result<(), JournalError> { + if block >= ERASE_BLOCKS { + return Err(JournalError::OutOfBounds); + } + self.erase_counts[block] += 1; + let start = block * ERASE_BLOCK_BYTES; + self.bytes[start..start + ERASE_BLOCK_BYTES].fill(0xff); + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ConfigMeta { + pub revision: u64, + pub payload_len: usize, + pub payload_crc32: u32, + pub start_block: usize, + pub span_blocks: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BootResult { + pub active: Option, + pub scanned_blocks: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Header { + revision: u64, + payload_len: usize, + payload_crc32: u32, + span_blocks: usize, +} + +impl Header { + fn encode(self) -> [u8; HEADER_BYTES] { + let mut output = [0xff; HEADER_BYTES]; + output[0..4].copy_from_slice(&MAGIC); + output[4..6].copy_from_slice(&FORMAT_VERSION.to_le_bytes()); + output[6..8].copy_from_slice(&(HEADER_BYTES as u16).to_le_bytes()); + output[8..16].copy_from_slice(&self.revision.to_le_bytes()); + output[16..20].copy_from_slice(&(self.payload_len as u32).to_le_bytes()); + output[20..24].copy_from_slice(&self.payload_crc32.to_le_bytes()); + output[24..26].copy_from_slice(&(self.span_blocks as u16).to_le_bytes()); + output[26..28].copy_from_slice(&0u16.to_le_bytes()); + let checksum = crc32(&output[..28]); + output[28..32].copy_from_slice(&checksum.to_le_bytes()); + output + } + + fn decode(input: &[u8; HEADER_BYTES]) -> Option { + if input[0..4] != MAGIC + || u16::from_le_bytes(input[4..6].try_into().ok()?) != FORMAT_VERSION + || u16::from_le_bytes(input[6..8].try_into().ok()?) != HEADER_BYTES as u16 + || u16::from_le_bytes(input[26..28].try_into().ok()?) != 0 + || u32::from_le_bytes(input[28..32].try_into().ok()?) != crc32(&input[..28]) + { + return None; + } + + let revision = u64::from_le_bytes(input[8..16].try_into().ok()?); + let payload_len = u32::from_le_bytes(input[16..20].try_into().ok()?) as usize; + let payload_crc32 = u32::from_le_bytes(input[20..24].try_into().ok()?); + let span_blocks = u16::from_le_bytes(input[24..26].try_into().ok()?) as usize; + + if revision == 0 + || !(MIN_CONFIG_BYTES..=MAX_CONFIG_BYTES).contains(&payload_len) + || span_blocks != blocks_for_payload(payload_len) + { + return None; + } + + Some(Self { + revision, + payload_len, + payload_crc32, + span_blocks, + }) + } +} + +pub fn scan(flash: &impl NorRead) -> Result { + let mut active = None; + + for block in 0..ERASE_BLOCKS { + if let Some(candidate) = read_candidate(flash, block)? + && active + .as_ref() + .is_none_or(|current: &ConfigMeta| candidate.revision > current.revision) + { + active = Some(candidate); + } + } + + Ok(BootResult { + active, + scanned_blocks: ERASE_BLOCKS, + }) +} + +pub fn write_config( + flash: &mut impl Nor, + revision: u64, + payload_len: usize, + source: &mut impl Read, +) -> Result { + if !(MIN_CONFIG_BYTES..=MAX_CONFIG_BYTES).contains(&payload_len) { + return Err(JournalError::InvalidConfigLength(payload_len)); + } + + let current = scan(flash)?.active; + if let Some(active) = current + && revision <= active.revision + { + return Err(JournalError::StaleRevision { + current: active.revision, + proposed: revision, + }); + } + if revision == 0 { + return Err(JournalError::StaleRevision { + current: current.map_or(0, |active| active.revision), + proposed: revision, + }); + } + + let span_blocks = blocks_for_payload(payload_len); + let start_block = current + .map(|active| (active.start_block + active.span_blocks) % ERASE_BLOCKS) + .unwrap_or(0); + if let Some(active) = current + && block_runs_overlap( + active.start_block, + active.span_blocks, + start_block, + span_blocks, + ) + { + return Err(JournalError::Capacity); + } + + for relative_block in 0..span_blocks { + flash.erase_block((start_block + relative_block) % ERASE_BLOCKS)?; + } + + let mut crc = Crc32::new(); + let mut remaining = payload_len; + let mut written = 0; + let mut buffer = [0u8; IO_CHUNK_BYTES]; + while remaining != 0 { + let wanted = remaining.min(buffer.len()); + read_exact_source(source, &mut buffer[..wanted])?; + crc.update(&buffer[..wanted]); + program_wrapped( + flash, + start_block * ERASE_BLOCK_BYTES + PAYLOAD_OFFSET + written, + &buffer[..wanted], + )?; + written += wanted; + remaining -= wanted; + } + if source.read(&mut buffer[..1])? != 0 { + return Err(JournalError::SourceLengthMismatch); + } + + let payload_crc32 = crc.finish(); + let header = Header { + revision, + payload_len, + payload_crc32, + span_blocks, + } + .encode(); + flash.program(start_block * ERASE_BLOCK_BYTES, &header)?; + flash.program( + start_block * ERASE_BLOCK_BYTES + COMMIT_OFFSET, + &[COMMITTED], + )?; + + Ok(ConfigMeta { + revision, + payload_len, + payload_crc32, + start_block, + span_blocks, + }) +} + +pub fn verify_pattern( + flash: &impl NorRead, + config: ConfigMeta, + seed: u8, +) -> Result { + let mut buffer = [0u8; IO_CHUNK_BYTES]; + let mut checked = 0; + while checked < config.payload_len { + let count = (config.payload_len - checked).min(buffer.len()); + read_wrapped( + flash, + config.start_block * ERASE_BLOCK_BYTES + PAYLOAD_OFFSET + checked, + &mut buffer[..count], + )?; + if buffer[..count] + .iter() + .enumerate() + .any(|(index, byte)| *byte != pattern_byte(seed, checked + index)) + { + return Ok(false); + } + checked += count; + } + Ok(true) +} + +pub fn corrupt_payload_byte( + flash: &mut impl Nor, + config: ConfigMeta, + starting_at: usize, +) -> Result { + for relative in starting_at..config.payload_len { + let address = + (config.start_block * ERASE_BLOCK_BYTES + PAYLOAD_OFFSET + relative) % FLASH_BYTES; + let mut before = [0u8; 1]; + flash.read(address, &mut before)?; + if before[0] != 0 { + flash.program(address, &[0])?; + return Ok(relative); + } + } + Err(JournalError::NorBitSetAttempt) +} + +#[derive(Clone, Debug)] +pub struct PatternReader { + seed: u8, + length: usize, + position: usize, +} + +impl PatternReader { + pub fn new(seed: u8, length: usize) -> Self { + Self { + seed, + length, + position: 0, + } + } +} + +impl Read for PatternReader { + fn read(&mut self, output: &mut [u8]) -> std::io::Result { + let count = (self.length - self.position).min(output.len()); + for (index, byte) in output[..count].iter_mut().enumerate() { + *byte = pattern_byte(self.seed, self.position + index); + } + self.position += count; + Ok(count) + } +} + +fn read_candidate( + flash: &impl NorRead, + start_block: usize, +) -> Result, JournalError> { + let base = start_block * ERASE_BLOCK_BYTES; + let mut commit = [0u8; 1]; + flash.read(base + COMMIT_OFFSET, &mut commit)?; + if commit[0] != COMMITTED { + return Ok(None); + } + + let mut encoded = [0u8; HEADER_BYTES]; + flash.read(base, &mut encoded)?; + let Some(header) = Header::decode(&encoded) else { + return Ok(None); + }; + + let mut crc = Crc32::new(); + let mut buffer = [0u8; IO_CHUNK_BYTES]; + let mut checked = 0; + while checked < header.payload_len { + let count = (header.payload_len - checked).min(buffer.len()); + read_wrapped(flash, base + PAYLOAD_OFFSET + checked, &mut buffer[..count])?; + crc.update(&buffer[..count]); + checked += count; + } + if crc.finish() != header.payload_crc32 { + return Ok(None); + } + + Ok(Some(ConfigMeta { + revision: header.revision, + payload_len: header.payload_len, + payload_crc32: header.payload_crc32, + start_block, + span_blocks: header.span_blocks, + })) +} + +fn read_wrapped( + flash: &impl NorRead, + offset: usize, + output: &mut [u8], +) -> Result<(), JournalError> { + let normalized = offset % FLASH_BYTES; + let first = output.len().min(FLASH_BYTES - normalized); + flash.read(normalized, &mut output[..first])?; + if first < output.len() { + flash.read(0, &mut output[first..])?; + } + Ok(()) +} + +fn program_wrapped(flash: &mut impl Nor, offset: usize, data: &[u8]) -> Result<(), JournalError> { + let normalized = offset % FLASH_BYTES; + let first = data.len().min(FLASH_BYTES - normalized); + flash.program(normalized, &data[..first])?; + if first < data.len() { + flash.program(0, &data[first..])?; + } + Ok(()) +} + +fn blocks_for_payload(payload_len: usize) -> usize { + (PAYLOAD_OFFSET + payload_len).div_ceil(ERASE_BLOCK_BYTES) +} + +fn block_runs_overlap(a_start: usize, a_len: usize, b_start: usize, b_len: usize) -> bool { + (0..a_len).any(|a| { + let block = (a_start + a) % ERASE_BLOCKS; + (0..b_len).any(|b| block == (b_start + b) % ERASE_BLOCKS) + }) +} + +fn check_range(offset: usize, length: usize) -> Result<(), JournalError> { + if offset + .checked_add(length) + .is_none_or(|end| end > FLASH_BYTES) + { + return Err(JournalError::OutOfBounds); + } + Ok(()) +} + +fn read_exact_source(source: &mut impl Read, output: &mut [u8]) -> Result<(), JournalError> { + let mut filled = 0; + while filled < output.len() { + match source.read(&mut output[filled..])? { + 0 => return Err(JournalError::SourceLengthMismatch), + count => filled += count, + } + } + Ok(()) +} + +fn pattern_byte(seed: u8, index: usize) -> u8 { + seed.wrapping_add((index as u8).wrapping_mul(37)) +} + +struct Crc32(u32); + +impl Crc32 { + fn new() -> Self { + Self(0xffff_ffff) + } + + fn update(&mut self, bytes: &[u8]) { + for byte in bytes { + self.0 ^= u32::from(*byte); + for _ in 0..8 { + self.0 = (self.0 >> 1) ^ (0xedb8_8320 & (0u32.wrapping_sub(self.0 & 1))); + } + } + } + + fn finish(self) -> u32 { + !self.0 + } +} + +fn crc32(bytes: &[u8]) -> u32 { + let mut crc = Crc32::new(); + crc.update(bytes); + crc.finish() +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use super::*; + + struct BoundaryAuditNor { + inner: MemoryNor, + enabled: bool, + old_revision: u64, + new_revision: u64, + boundaries: usize, + old_boundaries: usize, + new_boundaries: usize, + invalid_boundaries: usize, + unexpected_boundaries: usize, + } + + impl BoundaryAuditNor { + fn new() -> Self { + Self { + inner: MemoryNor::new(), + enabled: false, + old_revision: 0, + new_revision: 0, + boundaries: 0, + old_boundaries: 0, + new_boundaries: 0, + invalid_boundaries: 0, + unexpected_boundaries: 0, + } + } + + fn audit(&mut self) { + if !self.enabled { + return; + } + self.boundaries += 1; + match scan(self).and_then(|boot| boot.active.ok_or(JournalError::Capacity)) { + Ok(config) if config.revision == self.old_revision => self.old_boundaries += 1, + Ok(config) if config.revision == self.new_revision => self.new_boundaries += 1, + Ok(_) => self.unexpected_boundaries += 1, + Err(_) => self.invalid_boundaries += 1, + } + } + } + + impl NorRead for BoundaryAuditNor { + fn read(&self, offset: usize, output: &mut [u8]) -> Result<(), JournalError> { + self.inner.read(offset, output) + } + } + + impl Nor for BoundaryAuditNor { + fn program(&mut self, offset: usize, data: &[u8]) -> Result<(), JournalError> { + check_range(offset, data.len())?; + for (index, after) in data.iter().enumerate() { + let before = &mut self.inner.bytes[offset + index]; + if *before & after != *after { + return Err(JournalError::NorBitSetAttempt); + } + *before = *after; + self.audit(); + } + Ok(()) + } + + fn erase_block(&mut self, block: usize) -> Result<(), JournalError> { + if block >= ERASE_BLOCKS { + return Err(JournalError::OutOfBounds); + } + self.inner.erase_counts[block] += 1; + let start = block * ERASE_BLOCK_BYTES; + for offset in start..start + ERASE_BLOCK_BYTES { + self.inner.bytes[offset] = 0xff; + self.audit(); + } + Ok(()) + } + } + + #[test] + fn baseline_single_blob_has_a_boundary_with_no_valid_configuration() { + let mut flash = MemoryNor::new(); + baseline_write(&mut flash, 1, MIN_CONFIG_BYTES, 7).unwrap(); + assert_eq!(scan(&flash).unwrap().active.unwrap().revision, 1); + + // The first byte of a torn erase destroys the only header before + // any byte of the replacement exists. + flash.bytes[0] = 0xff; + assert!(scan(&flash).unwrap().active.is_none()); + } + + #[test] + fn every_byte_boundary_boots_the_complete_old_or_new_configuration() { + for new_length in [MIN_CONFIG_BYTES, MAX_CONFIG_BYTES] { + let mut flash = BoundaryAuditNor::new(); + write_config( + &mut flash, + 1, + MIN_CONFIG_BYTES, + &mut PatternReader::new(11, MIN_CONFIG_BYTES), + ) + .unwrap(); + + flash.enabled = true; + flash.old_revision = 1; + flash.new_revision = 2; + let new_config = write_config( + &mut flash, + 2, + new_length, + &mut PatternReader::new(29, new_length), + ) + .unwrap(); + + let expected_boundaries = + new_config.span_blocks * ERASE_BLOCK_BYTES + new_length + HEADER_BYTES + 1; + assert_eq!(flash.boundaries, expected_boundaries); + assert_eq!(flash.invalid_boundaries, 0); + assert_eq!(flash.unexpected_boundaries, 0); + assert_eq!(flash.new_boundaries, 1); + assert_eq!( + flash.old_boundaries + flash.new_boundaries, + flash.boundaries + ); + assert!(verify_pattern(&flash, new_config, 29).unwrap()); + println!("audited {expected_boundaries} byte boundaries for {new_length}-byte update"); + } + } + + #[test] + fn corrupt_latest_and_stale_revisions_are_rejected() { + let mut flash = MemoryNor::new(); + let old = write_config( + &mut flash, + 41, + MIN_CONFIG_BYTES, + &mut PatternReader::new(3, MIN_CONFIG_BYTES), + ) + .unwrap(); + let latest = write_config( + &mut flash, + 42, + MAX_CONFIG_BYTES, + &mut PatternReader::new(5, MAX_CONFIG_BYTES), + ) + .unwrap(); + corrupt_payload_byte(&mut flash, latest, 17).unwrap(); + + let booted = scan(&flash).unwrap().active.unwrap(); + assert_eq!(booted, old); + assert!(verify_pattern(&flash, booted, 3).unwrap()); + + let error = write_config( + &mut flash, + 40, + MIN_CONFIG_BYTES, + &mut PatternReader::new(8, MIN_CONFIG_BYTES), + ) + .unwrap_err(); + assert!(matches!(error, JournalError::StaleRevision { .. })); + } + + #[test] + fn ten_thousand_updates_keep_erase_counts_within_ten_percent() { + let mut flash = MemoryNor::new(); + for revision in 1..=10_000 { + write_config( + &mut flash, + revision, + MIN_CONFIG_BYTES, + &mut PatternReader::new(revision as u8, MIN_CONFIG_BYTES), + ) + .unwrap(); + } + + let minimum = *flash.erase_counts().iter().min().unwrap(); + let maximum = *flash.erase_counts().iter().max().unwrap(); + let average = flash + .erase_counts() + .iter() + .map(|count| *count as f64) + .sum::() + / ERASE_BLOCKS as f64; + let imbalance_percent = f64::from(maximum - minimum) / average * 100.0; + + assert!(imbalance_percent <= 10.0); + assert_eq!((minimum, maximum), (312, 313)); + println!( + "10,000 updates: erase count {minimum}..={maximum}, imbalance {imbalance_percent:.2}%" + ); + } + + #[test] + fn resource_bounds_and_boot_scan_are_bounded() { + let mut flash = MemoryNor::new(); + let config = write_config( + &mut flash, + 1, + MAX_CONFIG_BYTES, + &mut PatternReader::new(91, MAX_CONFIG_BYTES), + ) + .unwrap(); + let started = Instant::now(); + let boot = scan(&flash).unwrap(); + let elapsed = started.elapsed(); + + assert_eq!(flash.bytes.len(), FLASH_BYTES); + assert_eq!(boot.scanned_blocks, SCAN_BLOCK_LIMIT); + assert_eq!(boot.active, Some(config)); + assert!(elapsed < Duration::from_millis(50)); + println!( + "maximum record boot scan: {} blocks in {elapsed:?}", + boot.scanned_blocks + ); + } + + fn baseline_write( + flash: &mut MemoryNor, + revision: u64, + payload_len: usize, + seed: u8, + ) -> Result<(), JournalError> { + let span_blocks = blocks_for_payload(payload_len); + for block in 0..span_blocks { + flash.erase_block(block)?; + } + + let mut source = PatternReader::new(seed, payload_len); + let mut buffer = [0u8; IO_CHUNK_BYTES]; + let mut crc = Crc32::new(); + let mut written = 0; + while written < payload_len { + let count = (payload_len - written).min(buffer.len()); + read_exact_source(&mut source, &mut buffer[..count])?; + crc.update(&buffer[..count]); + flash.program(PAYLOAD_OFFSET + written, &buffer[..count])?; + written += count; + } + let header = Header { + revision, + payload_len, + payload_crc32: crc.finish(), + span_blocks, + } + .encode(); + flash.program(0, &header)?; + flash.program(COMMIT_OFFSET, &[COMMITTED]) + } +} diff --git a/developer-simulation/runs/2026-07-29--flash-config-journal/src/main.rs b/developer-simulation/runs/2026-07-29--flash-config-journal/src/main.rs new file mode 100644 index 0000000..ff52435 --- /dev/null +++ b/developer-simulation/runs/2026-07-29--flash-config-journal/src/main.rs @@ -0,0 +1,68 @@ +use std::path::PathBuf; +use std::time::Instant; + +use flash_config_journal::{ + ERASE_BLOCKS, FLASH_BYTES, FileNor, JOURNAL_WORKING_MEMORY_CEILING_BYTES, MAX_CONFIG_BYTES, + MIN_CONFIG_BYTES, PatternReader, corrupt_payload_byte, scan, verify_pattern, write_config, +}; + +fn main() -> Result<(), Box> { + let image_path = PathBuf::from("target/flash-config-journal-demo.bin"); + let mut flash = FileNor::create_fresh(&image_path)?; + + let old = write_config( + &mut flash, + 100, + MIN_CONFIG_BYTES, + &mut PatternReader::new(11, MIN_CONFIG_BYTES), + )?; + let new = write_config( + &mut flash, + 101, + MAX_CONFIG_BYTES, + &mut PatternReader::new(29, MAX_CONFIG_BYTES), + )?; + let update_erase_count = flash.erase_counts().iter().sum::(); + drop(flash); + + let reopened = FileNor::open(&image_path)?; + let started = Instant::now(); + let boot = scan(&reopened)?; + let boot_elapsed = started.elapsed(); + let active = boot.active.ok_or("no valid configuration after reopen")?; + if active != new || !verify_pattern(&reopened, active, 29)? { + return Err("reopened configuration is not the complete new value".into()); + } + println!( + "reopen: revision {}, {} bytes, {} blocks scanned, {:?}", + active.revision, active.payload_len, boot.scanned_blocks, boot_elapsed + ); + drop(reopened); + + let mut flash = FileNor::open(&image_path)?; + let corrupted_at = corrupt_payload_byte(&mut flash, new, 17)?; + drop(flash); + + let reopened = FileNor::open(&image_path)?; + let fallback = scan(&reopened)? + .active + .ok_or("no valid fallback after deterministic corruption")?; + if fallback != old || !verify_pattern(&reopened, fallback, 11)? { + return Err("corruption did not fall back to the complete old value".into()); + } + + println!( + "corruption: changed new payload byte {corrupted_at}; boot rejected revision {} and recovered revision {}", + new.revision, fallback.revision + ); + println!( + "model bounds: {FLASH_BYTES} flash bytes, {ERASE_BLOCKS} erase blocks, {}-byte explicit-buffer design budget; whole stack unresolved", + JOURNAL_WORKING_MEMORY_CEILING_BYTES + ); + println!( + "file-backed emulator: {} ({} erases during this process)", + image_path.display(), + update_erase_count + ); + Ok(()) +} diff --git a/developer-simulation/runs/2026-07-30--ci-lease-coordinator/Cargo.toml b/developer-simulation/runs/2026-07-30--ci-lease-coordinator/Cargo.toml new file mode 100644 index 0000000..0908d38 --- /dev/null +++ b/developer-simulation/runs/2026-07-30--ci-lease-coordinator/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ci-lease-coordinator" +version = "0.0.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +fold = { path = "../../../fold" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "warn" +pedantic = "allow" diff --git a/developer-simulation/runs/2026-07-30--ci-lease-coordinator/EVIDENCE.md b/developer-simulation/runs/2026-07-30--ci-lease-coordinator/EVIDENCE.md new file mode 100644 index 0000000..8112b0b --- /dev/null +++ b/developer-simulation/runs/2026-07-30--ci-lease-coordinator/EVIDENCE.md @@ -0,0 +1,271 @@ +# Evidence + +Date: 2026-07-30 +Checkout: sanitized current-main trial copy, corrected under skeptical review +before archival +Scope: only the public root README, public examples, Fold source/API, prior +reports during skeptical review, and this prototype were inspected. + +## Result against the acceptance criteria + +| Criterion | Observed result | Disposition | +| --- | --- | --- | +| No premature dependency readiness | Child stayed pending through lease and renewal; became ready in the same transaction as parent completion | Passed in the modeled graphs | +| At most one attempt commits a terminal result | Attempt 2 won after attempt 1 expired; the late attempt named attempt 2 and its object key and made no change | Passed in the modeled state machine | +| Replayed completion/heartbeat makes no further change | Versions were checked before and after duplicate and reordered delivery across all 25 crash pairs | Passed under the message-ID assumption | +| Expired lease reassigns within 5 seconds | Heartbeat at the exact deadline and completion far past it were rejected without mutation; the simulated scan made the job retry-ready 1 ms after the deadline and assigned attempt 2 | Passed in deterministic time; scheduler delay is not measured | +| Recovery accepts work within 10 seconds at 100,000 jobs | Final five release-mode reopen-plus-lease samples: 0.162–0.170 seconds | Passed for one local process | +| 2,000 updates/s, p99 below 50 ms | Final three 2,000-update release passes: 334,115–348,759/s with 0.108–0.126 ms p99 | Passed only as a 32-message batched embedded-store upper bound | +| Every rejected completion names winner or rule | Duplicate/late completion named attempt 2 and `objects/fencing/winner`; nonterminal rejection paths state the violated state/fence rule | Passed in covered rejection paths | +| Persistent state below 1 GiB | 16,309,086 bytes after 100,000-job seed, benchmark leases, and 6,000 renewals | Passed for the synthetic graph | +| Three concurrent coordinator replicas | Second concurrent Fold opener was rejected | Failed; decisive no-fit | + +Because multi-replica correctness is required, the overall decision is **no +fit**, even though the single-writer subset met the local checks. + +## Ordered discovery and friction trail + +1. Read the root `README.md` before any crate source. It presents Fold as a + durable incremental stream with fast materialized reads, and points new + users to the examples. +2. Read all four public examples and their manifests. `starter` showed atomic + writes and persistence; `timeseries` showed incremental derived tables; + `chat` explicitly used one thread that owns the stream; `search` showed + `KeyedStream` upsert/retraction behavior. +3. Evaluated the JSON-snapshot baseline before selecting Fold. The minimal + reproducer acknowledged an in-memory attempt-1 lease, restarted from the + older JSON snapshot, then gave a different worker the same attempt-1 fence. + Therefore periodic snapshots fail correctness. The measured in-place + sync-after-every-update variant is only a naïve full-rewrite lower bound: it + lacks temporary-file writing, atomic replacement, and directory sync, so it + is not a crash-safe repair. +4. Inspected Fold's public stream and table APIs. `KeyedStream::wtx` can read, + replace, and derive multiple records atomically, which fits dependency + readiness and attempt fencing for one writer. +5. Found the public concrete type awkward for a named coordinator struct + because pipeline types include predicates. A function-pointer predicate and + explicit type aliases kept the prototype small. +6. Built persistent job records plus derived ready/leased tables. No ANNy or + ESE feature is used; they do not fit this workload. +7. Added unit checks for readiness, replay idempotence, exact and far-past + expiry, fencing, and immutable results. Skeptical review found that the + first version could revive an expired lease; the coordinator added explicit + deadline checks to both heartbeat and completion before archival. +8. Added the process-level fault runner. Each of 100 child processes performed + one mutation, printed `ACK`, flushed it, and called `process::exit(77)`, + bypassing Rust destructors. The parent required that exact exit, reopened + Fold, checked state, delivered duplicates/reordered messages, and checked + that expired heartbeat and completion messages did not mutate the lease. +9. Ran the concurrent-open check. Fold rejected the second opener. This is + appropriate behavior for an embedded single-writer store, but it exposes + the scenario mismatch. +10. Ran formatting, tests, strict prototype lint, the release demonstration, + and repeated release measurements. +11. A warnings-denied Clippy run including dependencies failed on five + pre-existing `needless_range_loop` warnings in `anny`. The prototype-only + warnings-denied run passed. No unrelated library source was changed. + +## Exact commands and observed results + +### Initial public-material inspection + +```console +sed -n '1,260p' README.md +for f in examples/*/src/main.rs; do sed -n '1,260p' "$f"; done +for f in examples/*/Cargo.toml; do sed -n '1,180p' "$f"; done +``` + +Observed: the documented database component relevant to coordinator state was +Fold; the examples consistently constructed an owned local stream and did not +show multi-process writes, replication, or leader election. + +### Build and tests + +```console +cargo check -p ci-lease-coordinator +cargo test -p ci-lease-coordinator +``` + +Observed: + +```text +cargo check: passed +running 4 tests +test tests::expired_attempt_cannot_overwrite_winner ... ok +test tests::expired_messages_cannot_revive_or_complete_a_lease ... ok +test tests::duplicate_and_reordered_messages_do_not_mutate ... ok +test tests::dependency_only_becomes_ready_after_parent_terminal_commit ... ok +test result: ok. 4 passed; 0 failed +``` + +### Formatting + +```console +cargo fmt -p ci-lease-coordinator -- --check +``` + +Observed: passed with no output after formatting the prototype. + +The broader `cargo fmt --all -- --check` also reported pre-existing formatting +differences in `examples/search/src/main.rs`; this prototype did not alter that +file. + +### Strict lint + +```console +cargo clippy -p ci-lease-coordinator --all-targets --no-deps -- -D warnings +``` + +Observed: passed. + +The dependency-inclusive form: + +```console +cargo clippy -p ci-lease-coordinator --all-targets -- -D warnings +``` + +stopped in `anny` on five existing `clippy::needless_range_loop` warnings +(`hnsw.rs` at 316, 578, 623, and 640; `metric.rs` at 29). It did not identify a +prototype warning before stopping. + +### Release demonstration + +```console +cargo run --release -p ci-lease-coordinator -- demo +``` + +Observed: + +```text +baseline failure: acknowledged worker Some(7); restart leased Some(8); both received fencing attempt 1 +fault test passed: 100 forced process exits after ACK; duplicate/reordered messages were no-ops; heartbeat and completion observed after expiry were rejected without mutation; expiry became retry-ready in 1 ms; late attempt rejection: rejected completion for job 50: terminal winner is attempt 2, result objects/fencing/winner; rule says terminal results are immutable +multi-writer check: second concurrent Fold opener was rejected; this embedded single-writer component cannot host three active coordinator replicas +``` + +### Repeated release measurements + +```console +cargo run --release -p ci-lease-coordinator -- bench /tmp/bogkit-ci-lease-benchmark-trial-a.db +``` + +Observed: + +```text +JSON full rewrite (100,000 jobs, 20248904 bytes), repeated sync times: 0.030s, 0.028s, 0.029s +batched heartbeat pass 1: 2,000 updates in 0.006s = 338753 updates/s; message p99 commit latency 0.108 ms +batched heartbeat pass 2: 2,000 updates in 0.006s = 334115 updates/s; message p99 commit latency 0.108 ms +batched heartbeat pass 3: 2,000 updates in 0.006s = 348759 updates/s; message p99 commit latency 0.126 ms +Fold upper-bound sample: seed/checkpoint 0.473s; five reopen+lease times 0.169s, 0.162s, 0.165s, 0.168s, 0.170s; overall message p99 0.117 ms; persistent directory 16309086 bytes +``` + +The JSON file was about 20.2 MB. Its 28–30 ms in-place rewrite times are only a +lower bound for a crash-safe replacement protocol. Periodic rewrites retain the +demonstrated correctness hole. + +## Categorized findings + +### Correctness defect + +- **Baseline only:** acknowledging mutations held only in memory allows a + restart to forget attempt increments and lease ownership. Two workers can + receive the same attempt fence. +- No correctness defect was demonstrated in Fold's single-writer transaction + behavior. + +### Performance problem + +- **Baseline only:** the naïve in-place rewrite of the 100,000-job state is a + full 20.2 MB write. Its measured time is a lower bound, not a crash-safe + snapshot measurement, because the prototype does not use temporary-file + writing, atomic replacement, and directory sync. +- The Fold result is an optimistic local upper bound; no multi-replica, + network, object-store, or scheduler overhead was measured. + +### API friction + +- Fold pipeline types include closure types, which makes storing a composed + stream in a named coordinator struct awkward. Function-pointer predicates + and aliases were enough here. +- Ready and leased indexes were easy to express as filtered materialized + tables, but scheduling order and priority policy remain application logic. + +### Documentation gap + +- The root README and examples do not state the process/concurrency boundary + prominently. A new user has to reach the `SingleWriterTxDatabase` API or try + a second opener to discover it. +- No public example covers crash recovery, idempotent external messages, + fencing tokens, or the durability distinction between process crash and + power loss. + +### Missing capability + +- No consensus, leader lease, replica fencing, replicated log, or documented + multi-process compare-and-swap is present in the evaluated Fold surface. + Three active coordinator replicas therefore cannot safely share this state. + +### Poor product fit + +- The scenario makes three concurrent coordinators and correctness mandatory. + The missing distributed coordination is not a bounded throughput + optimization; it changes the system's authority model. BogKit is therefore + not a fit for the full coordinator. + +### Actual BogKit defect + +- None demonstrated. Rejecting a second embedded single-writer opener is not a + defect. The dependency-inclusive strict-lint failure is a repository quality + issue in ANNy, not evidence of a runtime correctness defect in Fold. + +## Decision audit + +1. **Rejected periodic JSON snapshots.** They acknowledge state that is not yet + durable, so the crash requirement fails. +2. **Rejected the naïve sync-after-every-update JSON comparison.** It requires + a full in-place rewrite per mutation and still lacks a crash-safe + replacement protocol. Its timing is only a lower bound for a correct + snapshot design. +3. **Selected only Fold.** ESE and ANNy solve embedding/search problems, not + durable coordination. +4. **Used keyed full job records as the source of truth.** This allows one + transaction to compare a fence, commit a result, and unlock dependents. +5. **Materialized ready and leased subsets.** This avoids scanning all 100,000 + jobs during recovery dispatch or expiry checks. +6. **Used coordinator-provided time only.** Worker clocks never determine a + deadline. +7. **Batched at most 32 heartbeats.** This is a bounded single-writer throughput + optimization; acknowledgment waits for the batch commit. +8. **Did not invent distributed wrapping.** Adding a consensus service, + leader-election system, or remote transactional database would be the real + coordinator authority and is outside BogKit and this prototype. +9. **Concluded no fit.** The multi-writer failure is correctness-critical and + cannot be excused by the strong single-process measurements. + +## Prototype limits and uncertainty + +- The synthetic upper-bound graph uses 10,000 builds with 10 jobs each, + arranged as independent chains. It reaches 100,000 jobs but does not cover + the full 1-500 job/build distribution or wide fan-in/fan-out. +- The fault run uses 25 two-job dependency graphs plus one fencing job. It + forces 100 post-ack process exits, but does not kill during the storage + commit itself because no acknowledgment exists before commit returns. +- `process::exit` bypasses Rust destructors, but it is not a power-loss test. + Fold documents `checkpoint` separately for OS/power durability. +- The 1 ms expiry result is simulated coordinator time and immediate scheduler + invocation. It proves state-machine eligibility, not a five-second + production scheduling service-level objective. +- Heartbeat replay idempotence assumes stable, increasing per-attempt message + IDs already exist. The worker protocol was unspecified and cannot be + changed; absence of that field is an unresolved scenario/protocol mismatch. +- Results are local-machine measurements from one run containing three update + passes, five recovery samples, and three JSON rewrites. They are not + cross-machine capacity guarantees. +- Batch latency excludes queueing time to collect up to 32 messages. A + production implementation would need a bounded flush timer below the 50 ms + objective. +- No network server, dependency discovery, artifact transfer, object-store + mutation, autoscaling, UI, authentication, or multi-region behavior is + included, matching the stated non-goals. +- The prototype has no three-replica implementation, because the evaluated + component provides no safe basis for one. This is the decisive scenario + mismatch, not a hidden prototype TODO. diff --git a/developer-simulation/runs/2026-07-30--ci-lease-coordinator/README.md b/developer-simulation/runs/2026-07-30--ci-lease-coordinator/README.md new file mode 100644 index 0000000..6ef5076 --- /dev/null +++ b/developer-simulation/runs/2026-07-30--ci-lease-coordinator/README.md @@ -0,0 +1,88 @@ +# CI lease coordinator trial + +This is a clean-room evaluation of BogKit's `fold` crate for durable CI +coordinator state. It is deliberately a prototype, not a production +coordinator. + +## Decision + +**BogKit is not a fit for the complete scenario.** After explicit +coordinator-time deadline checks, Fold handled the modeled single-active- +coordinator subset: one transaction can fence an attempt, commit an immutable +result, and make dependent jobs ready without exposing partial state. However, +Fold opens an embedded single-writer database and rejected a second concurrent +opener in this trial. It does not provide the consensus, leader fencing, or +replicated compare-and-swap needed by three concurrently running coordinator +replicas. + +The prototype therefore demonstrates only the useful single-writer subset. It +does not suggest placing an unsupported replication layer around Fold. + +## What it covers + +- Durable job, dependency, lease, attempt, terminal-result, and explanation + fields. +- Incrementally maintained ready and leased indexes. +- Coordinator-time lease acquisition, renewal, expiry, and reassignment. +- Exact-deadline and far-past-deadline rejection for heartbeats and + completions, without reviving the expired attempt. +- Attempt fencing and immutable terminal results. +- Atomic dependency readiness after a parent commits. +- Explicit reasons for blocked, retried, and rejected operations. +- A deterministic test that exits 100 child coordinator processes immediately + after a committed acknowledgment, then reopens state and injects duplicate + and reordered messages. +- A 100,000-job recovery, storage, JSON-baseline, and batched-update benchmark. + +## Run + +Run from `developer-simulation/`: + +```console +cargo run --release -p ci-lease-coordinator -- demo +cargo run --release -p ci-lease-coordinator -- bench /tmp/bogkit-ci-lease-benchmark.db +cargo test -p ci-lease-coordinator +cargo fmt -p ci-lease-coordinator -- --check +cargo clippy -p ci-lease-coordinator --all-targets --no-deps -- -D warnings +``` + +The subcommands can also be run separately: + +```console +cargo run --release -p ci-lease-coordinator -- baseline +cargo run --release -p ci-lease-coordinator -- fault /tmp/bogkit-ci-fault.db +cargo run --release -p ci-lease-coordinator -- multi-writer /tmp/bogkit-ci-writer.db +``` + +`demo` uses and resets fixed directories under the system temporary directory. +`bench` resets the database path passed on its command line and overwrites the +same path with a `.json` extension for the baseline measurement. Do not point +either command at valued data. + +## State-machine notes + +Every lease assigns a new attempt number. Heartbeats and completions must match +both the worker and attempt, and the coordinator must observe them strictly +before the stored deadline. Once a completion wins, its attempt and immutable +object-store key are stored in the terminal record; later messages return a +reason naming that winner and make no state change. + +A completion and all readiness changes it unlocks occur in one Fold +transaction. A child remains pending until every dependency has a terminal +record in that same transaction's view. + +The heartbeat replay test assumes the existing worker request already has a +stable, increasing message identifier within an attempt. The problem brief did +not specify the existing protocol's fields, and changing that protocol is a +non-goal. If the real protocol lacks such an identifier, exact heartbeat replay +idempotence is an unresolved protocol mismatch and this prototype is not +deployable. + +The benchmark groups at most 32 heartbeats into one durable transaction and +acknowledges them after that transaction commits. Its reported latency is the +batch commit time assigned to each message; it excludes time waiting to form a +batch, networking, request parsing, and replica coordination. Treat it as an +embedded-store upper bound. + +See [EVIDENCE.md](EVIDENCE.md) for the complete trail, observed output, limits, +and decision audit. diff --git a/developer-simulation/runs/2026-07-30--ci-lease-coordinator/src/main.rs b/developer-simulation/runs/2026-07-30--ci-lease-coordinator/src/main.rs new file mode 100644 index 0000000..0dfc0d7 --- /dev/null +++ b/developer-simulation/runs/2026-07-30--ci-lease-coordinator/src/main.rs @@ -0,0 +1,1092 @@ +use std::fs::{self, File}; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; +use std::time::{Duration, Instant}; + +use fold::pipeline::{Filter, Keyed, terminal}; +use fold::stream::KeyedStream; +use serde::{Deserialize, Serialize}; + +const LEASE_MS: u64 = 30_000; +const FORCED_EXIT_CODE: i32 = 77; + +type JobTable = terminal::Table; +type JobFilter = Filter, fn(&Keyed) -> bool, JobTable>; +type JobPipeline = (JobTable, JobFilter, JobFilter); +type JobStore = KeyedStream; + +#[derive(Clone, Debug, Deserialize, Serialize)] +enum Status { + Pending, + Ready, + Leased { owner: u32, deadline_ms: u64 }, + Completed { attempt: u32, result_key: String }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct Job { + build_id: u32, + dependencies: Vec, + dependents: Vec, + attempt: u32, + status: Status, + last_heartbeat_message_id: u64, + terminal_message_id: Option, + version: u64, + reason: String, +} + +#[derive(Debug)] +struct Outcome { + changed: bool, + detail: String, +} + +#[derive(Clone, Copy)] +struct Heartbeat { + job_id: u32, + worker: u32, + attempt: u32, + message_id: u64, + coordinator_now_ms: u64, +} + +struct Coordinator { + store: JobStore, +} + +impl Coordinator { + fn open(path: &Path) -> Self { + let pipeline = ( + terminal::Table::new("jobs"), + Filter::new( + is_ready as fn(&Keyed) -> bool, + terminal::Table::new("ready"), + ), + Filter::new( + is_leased as fn(&Keyed) -> bool, + terminal::Table::new("leased"), + ), + ); + Self { + store: KeyedStream::new(path, pipeline), + } + } + + fn seed_pairs(&mut self, pairs: u32) { + self.store.wtx(|tx| { + for pair in 0..pairs { + let root = pair * 2; + let child = root + 1; + tx.upsert( + &root, + &Job { + build_id: pair, + dependencies: vec![], + dependents: vec![child], + attempt: 0, + status: Status::Ready, + last_heartbeat_message_id: 0, + terminal_message_id: None, + version: 1, + reason: "ready: no dependencies".to_string(), + }, + ); + tx.upsert( + &child, + &Job { + build_id: pair, + dependencies: vec![root], + dependents: vec![], + attempt: 0, + status: Status::Pending, + last_heartbeat_message_id: 0, + terminal_message_id: None, + version: 1, + reason: format!("blocked by unfinished job {root}"), + }, + ); + } + let fencing_job = pairs * 2; + tx.upsert( + &fencing_job, + &Job { + build_id: pairs, + dependencies: vec![], + dependents: vec![], + attempt: 0, + status: Status::Ready, + last_heartbeat_message_id: 0, + terminal_message_id: None, + version: 1, + reason: "ready: no dependencies".to_string(), + }, + ); + }); + } + + fn seed_benchmark_graph(&mut self, builds: u32, jobs_per_build: u32) { + self.store.wtx(|tx| { + for build in 0..builds { + for offset in 0..jobs_per_build { + let id = build * jobs_per_build + offset; + let dependencies = if offset > 0 { vec![id - 1] } else { Vec::new() }; + let dependents = if offset + 1 < jobs_per_build { + vec![id + 1] + } else { + Vec::new() + }; + let (status, reason) = if dependencies.is_empty() { + (Status::Ready, "ready: no dependencies".to_string()) + } else { + ( + Status::Pending, + format!("blocked by unfinished job {}", id - 1), + ) + }; + tx.upsert( + &id, + &Job { + build_id: build, + dependencies, + dependents, + attempt: 0, + status, + last_heartbeat_message_id: 0, + terminal_message_id: None, + version: 1, + reason, + }, + ); + } + } + }); + } + + fn job(&self, id: u32) -> Job { + self.store + .get(&id) + .unwrap_or_else(|| panic!("missing job {id}")) + } + + fn next_ready(&self) -> Option { + self.store + .rtx(|(_, ready, _)| ready.iter().next().map(|(id, _)| id)) + } + + fn lease_next(&mut self, worker: u32, now_ms: u64) -> Option<(u32, Outcome)> { + let id = self.next_ready()?; + Some((id, self.lease_job(id, worker, now_ms))) + } + + fn lease_job(&mut self, id: u32, worker: u32, now_ms: u64) -> Outcome { + self.store.wtx(|tx| { + let Some(mut job) = tx.get(&id) else { + return Outcome { + changed: false, + detail: format!("rejected: job {id} does not exist"), + }; + }; + if !matches!(job.status, Status::Ready) { + return Outcome { + changed: false, + detail: rejection_for_non_ready(id, &job), + }; + } + job.attempt += 1; + job.last_heartbeat_message_id = 0; + job.status = Status::Leased { + owner: worker, + deadline_ms: now_ms + LEASE_MS, + }; + job.version += 1; + job.reason = format!( + "leased to worker {worker} as attempt {} until {} by coordinator time", + job.attempt, + now_ms + LEASE_MS + ); + let attempt = job.attempt; + tx.upsert(&id, &job); + Outcome { + changed: true, + detail: format!("leased job {id}: worker {worker}, attempt {attempt}"), + } + }) + } + + fn heartbeat(&mut self, heartbeat: Heartbeat) -> Outcome { + self.heartbeat_batch(&[heartbeat]) + .into_iter() + .next() + .expect("one heartbeat outcome") + } + + fn heartbeat_batch(&mut self, heartbeats: &[Heartbeat]) -> Vec { + self.store.wtx(|tx| { + heartbeats + .iter() + .map(|heartbeat| { + let Some(mut job) = tx.get(&heartbeat.job_id) else { + return Outcome { + changed: false, + detail: format!( + "rejected heartbeat: job {} does not exist", + heartbeat.job_id + ), + }; + }; + let Status::Leased { owner, deadline_ms } = job.status else { + return Outcome { + changed: false, + detail: rejection_for_message("heartbeat", heartbeat.job_id, &job), + }; + }; + if owner != heartbeat.worker || job.attempt != heartbeat.attempt { + return Outcome { + changed: false, + detail: format!( + "rejected heartbeat for job {}: active fence is worker {owner}, attempt {}; rule requires both to match", + heartbeat.job_id, job.attempt + ), + }; + } + if deadline_ms <= heartbeat.coordinator_now_ms { + return Outcome { + changed: false, + detail: format!( + "rejected heartbeat for job {}: attempt {} expired at {deadline_ms}; coordinator observed {}; rule forbids reviving an expired lease", + heartbeat.job_id, + job.attempt, + heartbeat.coordinator_now_ms + ), + }; + } + if heartbeat.message_id <= job.last_heartbeat_message_id { + return Outcome { + changed: false, + detail: format!( + "rejected heartbeat for job {}: message {} did not advance winner {} for attempt {}; rule requires a stable increasing per-attempt message id", + heartbeat.job_id, + heartbeat.message_id, + job.last_heartbeat_message_id, + job.attempt + ), + }; + } + let new_deadline = + deadline_ms.max(heartbeat.coordinator_now_ms.saturating_add(LEASE_MS)); + job.last_heartbeat_message_id = heartbeat.message_id; + job.status = Status::Leased { + owner, + deadline_ms: new_deadline, + }; + job.version += 1; + job.reason = format!( + "lease renewed by coordinator message {} until {new_deadline}", + heartbeat.message_id + ); + tx.upsert(&heartbeat.job_id, &job); + Outcome { + changed: true, + detail: format!( + "renewed job {} attempt {} until {new_deadline}", + heartbeat.job_id, heartbeat.attempt + ), + } + }) + .collect() + }) + } + + fn complete( + &mut self, + id: u32, + worker: u32, + attempt: u32, + message_id: u64, + coordinator_now_ms: u64, + result_key: &str, + ) -> Outcome { + self.store.wtx(|tx| { + let Some(mut job) = tx.get(&id) else { + return Outcome { + changed: false, + detail: format!("rejected completion: job {id} does not exist"), + }; + }; + let Status::Leased { owner, deadline_ms } = &job.status else { + return Outcome { + changed: false, + detail: rejection_for_message("completion", id, &job), + }; + }; + if *owner != worker || job.attempt != attempt { + return Outcome { + changed: false, + detail: format!( + "rejected completion for job {id}: active fence is worker {owner}, attempt {}; rule requires both to match", + job.attempt + ), + }; + } + if *deadline_ms <= coordinator_now_ms { + return Outcome { + changed: false, + detail: format!( + "rejected completion for job {id}: attempt {} expired at {deadline_ms}; coordinator observed {coordinator_now_ms}; rule forbids an expired attempt from winning", + job.attempt + ), + }; + } + + let dependents = job.dependents.clone(); + job.status = Status::Completed { + attempt, + result_key: result_key.to_string(), + }; + job.terminal_message_id = Some(message_id); + job.version += 1; + job.reason = format!( + "terminal result committed by worker {worker}, attempt {attempt}, message {message_id}" + ); + tx.upsert(&id, &job); + + for dependent_id in dependents { + let Some(mut dependent) = tx.get(&dependent_id) else { + continue; + }; + if !matches!(dependent.status, Status::Pending) { + continue; + } + let mut blocked = Vec::new(); + for dependency_id in &dependent.dependencies { + let complete = tx + .get(dependency_id) + .is_some_and(|parent| matches!(parent.status, Status::Completed { .. })); + if !complete { + blocked.push(*dependency_id); + } + } + dependent.version += 1; + if blocked.is_empty() { + dependent.status = Status::Ready; + dependent.reason = + format!("ready: all dependencies completed after job {id}"); + } else { + dependent.reason = format!("blocked by unfinished jobs {blocked:?}"); + } + tx.upsert(&dependent_id, &dependent); + } + + Outcome { + changed: true, + detail: format!( + "committed terminal winner for job {id}: attempt {attempt}, result {result_key}" + ), + } + }) + } + + fn reap_expired(&mut self, now_ms: u64) -> usize { + let expired: Vec = self.store.rtx(|(_, _, leased)| { + leased + .iter() + .filter_map(|(id, job)| match job.status { + Status::Leased { deadline_ms, .. } if deadline_ms <= now_ms => Some(id), + _ => None, + }) + .collect() + }); + self.store.wtx(|tx| { + for id in &expired { + let Some(mut job) = tx.get(id) else { + continue; + }; + let Status::Leased { deadline_ms, .. } = job.status else { + continue; + }; + if deadline_ms > now_ms { + continue; + } + job.status = Status::Ready; + job.version += 1; + job.reason = format!( + "retry ready: attempt {} expired at {deadline_ms}; coordinator observed {now_ms}", + job.attempt + ); + tx.upsert(id, &job); + } + }); + expired.len() + } + + fn checkpoint(&mut self) { + self.store.checkpoint(); + } +} + +fn is_ready(job: &Keyed) -> bool { + matches!(job.val.status, Status::Ready) +} + +fn is_leased(job: &Keyed) -> bool { + matches!(job.val.status, Status::Leased { .. }) +} + +fn rejection_for_non_ready(id: u32, job: &Job) -> String { + match &job.status { + Status::Pending => format!("rejected lease for job {id}: {}", job.reason), + Status::Leased { owner, .. } => format!( + "rejected lease for job {id}: winner is active worker {owner}, attempt {}; rule forbids a second live lease", + job.attempt + ), + Status::Completed { + attempt, + result_key, + } => format!( + "rejected lease for job {id}: terminal winner is attempt {attempt}, result {result_key}; rule forbids retry after completion" + ), + Status::Ready => unreachable!("caller checks ready"), + } +} + +fn rejection_for_message(kind: &str, id: u32, job: &Job) -> String { + match &job.status { + Status::Completed { + attempt, + result_key, + } => format!( + "rejected {kind} for job {id}: terminal winner is attempt {attempt}, result {result_key}; rule says terminal results are immutable" + ), + Status::Pending => format!("rejected {kind} for job {id}: {}", job.reason), + Status::Ready => format!( + "rejected {kind} for job {id}: no active lease; rule requires a matching active attempt" + ), + Status::Leased { .. } => unreachable!("caller handles leased"), + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct SnapshotJob { + attempt: u32, + lease_owner: Option, +} + +fn baseline_demo() { + let durable_snapshot = serde_json::to_vec(&vec![SnapshotJob { + attempt: 0, + lease_owner: None, + }]) + .expect("encode baseline snapshot"); + let mut in_memory: Vec = + serde_json::from_slice(&durable_snapshot).expect("load baseline snapshot"); + in_memory[0].attempt += 1; + in_memory[0].lease_owner = Some(7); + let first_ack = in_memory[0].clone(); + + let mut after_crash: Vec = + serde_json::from_slice(&durable_snapshot).expect("reload baseline snapshot"); + after_crash[0].attempt += 1; + after_crash[0].lease_owner = Some(8); + let second_ack = after_crash[0].clone(); + + println!( + "baseline failure: acknowledged worker {:?}; restart leased {:?}; both received fencing attempt {}", + first_ack.lease_owner, second_ack.lease_owner, second_ack.attempt + ); +} + +fn crash_child(args: &[String]) -> ExitCode { + let path = Path::new(&args[2]); + let operation = &args[3]; + let id: u32 = args[4].parse().expect("job id"); + let worker: u32 = args[5].parse().expect("worker"); + let mut coordinator = Coordinator::open(path); + let outcome = match operation.as_str() { + "lease" => { + let now_ms: u64 = args[6].parse().expect("coordinator time"); + coordinator.lease_job(id, worker, now_ms) + } + "heartbeat" => { + let attempt: u32 = args[6].parse().expect("attempt"); + let message_id: u64 = args[7].parse().expect("message id"); + let now_ms: u64 = args[8].parse().expect("coordinator time"); + coordinator.heartbeat(Heartbeat { + job_id: id, + worker, + attempt, + message_id, + coordinator_now_ms: now_ms, + }) + } + "complete" => { + let attempt: u32 = args[6].parse().expect("attempt"); + let message_id: u64 = args[7].parse().expect("message id"); + let now_ms: u64 = args[8].parse().expect("coordinator time"); + coordinator.complete(id, worker, attempt, message_id, now_ms, &args[9]) + } + other => panic!("unknown crash operation {other}"), + }; + assert!( + outcome.changed, + "crash step must mutate: {}", + outcome.detail + ); + println!("ACK {}", outcome.detail); + std::io::stdout().flush().expect("flush ack"); + std::process::exit(FORCED_EXIT_CODE); +} + +fn run_crash_step(path: &Path, operation: &[String]) { + let output = Command::new(std::env::current_exe().expect("current executable")) + .arg("crash-step") + .arg(path) + .args(operation) + .output() + .expect("spawn forced-termination child"); + assert_eq!(output.status.code(), Some(FORCED_EXIT_CODE)); + let stdout = String::from_utf8(output.stdout).expect("child stdout"); + assert!(stdout.starts_with("ACK "), "missing child ack: {stdout}"); +} + +fn fault_demo(path: &Path) { + reset_dir(path); + { + let mut coordinator = Coordinator::open(path); + coordinator.seed_pairs(25); + } + + let mut terminations = 0; + for pair in 0..25u32 { + let root = pair * 2; + let child = root + 1; + let root_worker = 1_000 + root; + let child_worker = 2_000 + child; + let heartbeat_id = 10_000 + u64::from(root); + + run_crash_step( + path, + &[ + "lease".to_string(), + root.to_string(), + root_worker.to_string(), + "100".to_string(), + ], + ); + terminations += 1; + { + let mut coordinator = Coordinator::open(path); + assert!(matches!(coordinator.job(child).status, Status::Pending)); + let before = coordinator.job(root).version; + let duplicate = coordinator.lease_job(root, root_worker, 100); + assert!(!duplicate.changed); + assert_eq!(coordinator.job(root).version, before); + } + + run_crash_step( + path, + &[ + "heartbeat".to_string(), + root.to_string(), + root_worker.to_string(), + "1".to_string(), + heartbeat_id.to_string(), + "1_000".replace('_', ""), + ], + ); + terminations += 1; + { + let mut coordinator = Coordinator::open(path); + let before = coordinator.job(root).version; + let duplicate = coordinator.heartbeat(Heartbeat { + job_id: root, + worker: root_worker, + attempt: 1, + message_id: heartbeat_id, + coordinator_now_ms: 2_000, + }); + assert!(!duplicate.changed); + assert_eq!(coordinator.job(root).version, before); + } + + run_crash_step( + path, + &[ + "complete".to_string(), + root.to_string(), + root_worker.to_string(), + "1".to_string(), + (20_000 + u64::from(root)).to_string(), + "2_500".replace('_', ""), + format!("objects/build-{pair}/root"), + ], + ); + terminations += 1; + { + let mut coordinator = Coordinator::open(path); + assert!(matches!(coordinator.job(child).status, Status::Ready)); + let before = coordinator.job(root).version; + let replay = coordinator.complete( + root, + root_worker, + 1, + 20_000 + u64::from(root), + 2_500, + &format!("objects/build-{pair}/root"), + ); + assert!(!replay.changed); + assert!(replay.detail.contains("terminal winner")); + let reordered_heartbeat = coordinator.heartbeat(Heartbeat { + job_id: root, + worker: root_worker, + attempt: 1, + message_id: heartbeat_id + 1, + coordinator_now_ms: 3_000, + }); + assert!(!reordered_heartbeat.changed); + assert_eq!(coordinator.job(root).version, before); + } + + run_crash_step( + path, + &[ + "lease".to_string(), + child.to_string(), + child_worker.to_string(), + "4_000".replace('_', ""), + ], + ); + terminations += 1; + { + let mut coordinator = Coordinator::open(path); + let before = coordinator.job(child).version; + let duplicate = coordinator.lease_job(child, child_worker, 4_000); + assert!(!duplicate.changed); + assert_eq!(coordinator.job(child).version, before); + } + } + assert_eq!(terminations, 100); + + let fencing_job = 50; + let mut coordinator = Coordinator::open(path); + assert!(coordinator.lease_job(fencing_job, 41, 0).changed); + let before_expired_messages = coordinator.job(fencing_job).version; + let expired_heartbeat = coordinator.heartbeat(Heartbeat { + job_id: fencing_job, + worker: 41, + attempt: 1, + message_id: 90_000, + coordinator_now_ms: 30_001, + }); + assert!(!expired_heartbeat.changed); + assert!(expired_heartbeat.detail.contains("expired at 30000")); + let expired_completion = coordinator.complete( + fencing_job, + 41, + 1, + 90_001, + 30_001, + "objects/fencing/expired", + ); + assert!(!expired_completion.changed); + assert!(expired_completion.detail.contains("expired at 30000")); + assert_eq!( + coordinator.job(fencing_job).version, + before_expired_messages + ); + assert_eq!(coordinator.reap_expired(30_001), 1); + let retry_ready_after_ms = 1; + assert!(coordinator.lease_job(fencing_job, 42, 30_001).changed); + assert_eq!(coordinator.job(fencing_job).attempt, 2); + let winner = coordinator.complete(fencing_job, 42, 2, 90_002, 30_002, "objects/fencing/winner"); + assert!(winner.changed); + let before = coordinator.job(fencing_job).version; + let late = coordinator.complete(fencing_job, 41, 1, 90_003, 30_003, "objects/fencing/late"); + assert!(!late.changed); + assert!(late.detail.contains("attempt 2")); + assert!(late.detail.contains("objects/fencing/winner")); + assert_eq!(coordinator.job(fencing_job).version, before); + + println!( + "fault test passed: {terminations} forced process exits after ACK; duplicate/reordered messages were no-ops; heartbeat and completion observed after expiry were rejected without mutation; expiry became retry-ready in {retry_ready_after_ms} ms; late attempt rejection: {}", + late.detail + ); +} + +fn multi_writer_demo(path: &Path) { + reset_dir(path); + let _first = Coordinator::open(path); + let old_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let second = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Coordinator::open(path))); + std::panic::set_hook(old_hook); + match second { + Ok(_) => println!( + "multi-writer check: a second handle opened, but Fold exposes no replica consensus, leader fencing, or cross-process compare-and-swap contract; requirement remains unproven" + ), + Err(_) => println!( + "multi-writer check: second concurrent Fold opener was rejected; this embedded single-writer component cannot host three active coordinator replicas" + ), + } +} + +fn benchmark(path: &Path) { + reset_dir(path); + let json_path = path.with_extension("json"); + let seed_jobs = benchmark_jobs(10_000, 10); + let mut json_durations = Vec::new(); + let mut json_bytes = 0; + for _ in 0..3 { + let started = Instant::now(); + let file = File::create(&json_path).expect("create JSON snapshot"); + let mut writer = BufWriter::new(file); + serde_json::to_writer(&mut writer, &seed_jobs).expect("write JSON snapshot"); + writer.flush().expect("flush JSON"); + writer.get_ref().sync_all().expect("sync JSON snapshot"); + json_durations.push(started.elapsed()); + json_bytes = writer.get_ref().metadata().expect("JSON metadata").len(); + } + println!( + "JSON full rewrite (100,000 jobs, {} bytes), repeated sync times: {}", + json_bytes, + display_durations(&json_durations) + ); + + let seed_started = Instant::now(); + let mut coordinator = Coordinator::open(path); + coordinator.seed_benchmark_graph(10_000, 10); + coordinator.checkpoint(); + let seed_elapsed = seed_started.elapsed(); + drop(coordinator); + + let mut recovery_times = Vec::new(); + for worker in 0..5 { + let started = Instant::now(); + let mut recovered = Coordinator::open(path); + let lease = recovered + .lease_next(50_000 + worker, 100) + .expect("ready work after recovery"); + assert!(lease.1.changed); + recovery_times.push(started.elapsed()); + } + + let mut coordinator = Coordinator::open(path); + let mut leased = Vec::new(); + for worker in 0..500 { + let (id, outcome) = coordinator + .lease_next(60_000 + worker, 1_000) + .expect("500 ready benchmark jobs"); + assert!(outcome.changed); + leased.push((id, 60_000 + worker, coordinator.job(id).attempt)); + } + + let mut all_message_latencies = Vec::new(); + for pass in 0..3u64 { + let pass_started = Instant::now(); + let mut pass_latencies = Vec::with_capacity(2_000); + for batch_start in (0..2_000usize).step_by(32) { + let batch_end = (batch_start + 32).min(2_000); + let mut batch = Vec::with_capacity(batch_end - batch_start); + for update in batch_start..batch_end { + let (job_id, worker, attempt) = leased[update % leased.len()]; + batch.push(Heartbeat { + job_id, + worker, + attempt, + message_id: pass * 2_000 + update as u64 + 1, + coordinator_now_ms: 2_000 + pass, + }); + } + let started = Instant::now(); + let outcomes = coordinator.heartbeat_batch(&batch); + let elapsed = started.elapsed(); + assert!(outcomes.iter().all(|outcome| outcome.changed)); + pass_latencies.extend(std::iter::repeat_n(elapsed, batch.len())); + } + let wall = pass_started.elapsed(); + let rate = 2_000.0 / wall.as_secs_f64(); + let p99 = percentile_99(&mut pass_latencies); + println!( + "batched heartbeat pass {}: 2,000 updates in {:.3}s = {:.0} updates/s; message p99 commit latency {:.3} ms", + pass + 1, + wall.as_secs_f64(), + rate, + p99.as_secs_f64() * 1_000.0 + ); + all_message_latencies.extend(pass_latencies); + } + coordinator.checkpoint(); + drop(coordinator); + + let state_bytes = directory_size(path); + let overall_p99 = percentile_99(&mut all_message_latencies); + println!( + "Fold upper-bound sample: seed/checkpoint {:.3}s; five reopen+lease times {}; overall message p99 {:.3} ms; persistent directory {} bytes", + seed_elapsed.as_secs_f64(), + display_durations(&recovery_times), + overall_p99.as_secs_f64() * 1_000.0, + state_bytes + ); +} + +fn benchmark_jobs(builds: u32, jobs_per_build: u32) -> Vec { + let mut jobs = Vec::with_capacity((builds * jobs_per_build) as usize); + for build in 0..builds { + for offset in 0..jobs_per_build { + let id = build * jobs_per_build + offset; + jobs.push(Job { + build_id: build, + dependencies: if offset > 0 { vec![id - 1] } else { Vec::new() }, + dependents: if offset + 1 < jobs_per_build { + vec![id + 1] + } else { + Vec::new() + }, + attempt: 0, + status: if offset == 0 { + Status::Ready + } else { + Status::Pending + }, + last_heartbeat_message_id: 0, + terminal_message_id: None, + version: 1, + reason: if offset == 0 { + "ready: no dependencies".to_string() + } else { + format!("blocked by unfinished job {}", id - 1) + }, + }); + } + } + jobs +} + +fn percentile_99(durations: &mut [Duration]) -> Duration { + durations.sort_unstable(); + durations[(durations.len() * 99 / 100).min(durations.len() - 1)] +} + +fn display_durations(durations: &[Duration]) -> String { + durations + .iter() + .map(|duration| format!("{:.3}s", duration.as_secs_f64())) + .collect::>() + .join(", ") +} + +fn directory_size(path: &Path) -> u64 { + fs::read_dir(path) + .expect("read state directory") + .map(|entry| { + let entry = entry.expect("state entry"); + let metadata = entry.metadata().expect("state metadata"); + if metadata.is_dir() { + directory_size(&entry.path()) + } else { + metadata.len() + } + }) + .sum() +} + +fn reset_dir(path: &Path) { + if path.exists() { + fs::remove_dir_all(path).expect("remove prior demonstration state"); + } +} + +fn demo_paths() -> (PathBuf, PathBuf) { + let base = std::env::temp_dir(); + ( + base.join("bogkit-ci-lease-fault-demo.db"), + base.join("bogkit-ci-lease-multi-writer.db"), + ) +} + +fn usage() { + eprintln!( + "usage: ci-lease-coordinator " + ); +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + match args.get(1).map(String::as_str) { + Some("crash-step") => crash_child(&args), + Some("baseline") => { + baseline_demo(); + ExitCode::SUCCESS + } + Some("fault") => { + let path = args + .get(2) + .map(PathBuf::from) + .unwrap_or_else(|| demo_paths().0); + fault_demo(&path); + ExitCode::SUCCESS + } + Some("multi-writer") => { + let path = args + .get(2) + .map(PathBuf::from) + .unwrap_or_else(|| demo_paths().1); + multi_writer_demo(&path); + ExitCode::SUCCESS + } + Some("bench") => { + let path = args + .get(2) + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("bogkit-ci-lease-benchmark.db")); + benchmark(&path); + ExitCode::SUCCESS + } + Some("demo") => { + let (fault_path, multi_writer_path) = demo_paths(); + baseline_demo(); + fault_demo(&fault_path); + multi_writer_demo(&multi_writer_path); + ExitCode::SUCCESS + } + _ => { + usage(); + ExitCode::from(2) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_DB: AtomicU64 = AtomicU64::new(0); + + fn test_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "bogkit-ci-{name}-{}-{}", + std::process::id(), + NEXT_DB.fetch_add(1, Ordering::Relaxed) + )) + } + + #[test] + fn dependency_only_becomes_ready_after_parent_terminal_commit() { + let path = test_path("dependency"); + let mut coordinator = Coordinator::open(&path); + coordinator.seed_pairs(1); + assert!(matches!(coordinator.job(1).status, Status::Pending)); + assert!(coordinator.lease_job(0, 7, 0).changed); + assert!(matches!(coordinator.job(1).status, Status::Pending)); + assert!( + coordinator + .complete(0, 7, 1, 1, 1_000, "objects/parent") + .changed + ); + assert!(matches!(coordinator.job(1).status, Status::Ready)); + } + + #[test] + fn duplicate_and_reordered_messages_do_not_mutate() { + let path = test_path("replay"); + let mut coordinator = Coordinator::open(&path); + coordinator.seed_pairs(1); + assert!(coordinator.lease_job(0, 7, 0).changed); + let heartbeat = Heartbeat { + job_id: 0, + worker: 7, + attempt: 1, + message_id: 10, + coordinator_now_ms: 1_000, + }; + assert!(coordinator.heartbeat(heartbeat).changed); + let after_first = coordinator.job(0).version; + assert!(!coordinator.heartbeat(heartbeat).changed); + assert_eq!(coordinator.job(0).version, after_first); + assert!( + coordinator + .complete(0, 7, 1, 11, 2_000, "objects/winner") + .changed + ); + let after_complete = coordinator.job(0).version; + assert!( + !coordinator + .heartbeat(Heartbeat { + message_id: 12, + ..heartbeat + }) + .changed + ); + assert!( + !coordinator + .complete(0, 7, 1, 11, 2_000, "objects/winner") + .changed + ); + assert_eq!(coordinator.job(0).version, after_complete); + } + + #[test] + fn expired_attempt_cannot_overwrite_winner() { + let path = test_path("fence"); + let mut coordinator = Coordinator::open(&path); + coordinator.seed_pairs(0); + assert!(coordinator.lease_job(0, 7, 0).changed); + assert_eq!(coordinator.reap_expired(30_001), 1); + assert!(coordinator.lease_job(0, 8, 30_001).changed); + assert!( + coordinator + .complete(0, 8, 2, 2, 30_002, "objects/winner") + .changed + ); + let before = coordinator.job(0).version; + let late = coordinator.complete(0, 7, 1, 1, 30_003, "objects/late"); + assert!(!late.changed); + assert!(late.detail.contains("attempt 2")); + assert!(late.detail.contains("objects/winner")); + assert_eq!(coordinator.job(0).version, before); + } + + #[test] + fn expired_messages_cannot_revive_or_complete_a_lease() { + let path = test_path("expired-messages"); + let mut coordinator = Coordinator::open(&path); + coordinator.seed_pairs(0); + assert!(coordinator.lease_job(0, 7, 0).changed); + let leased_version = coordinator.job(0).version; + + let at_deadline = coordinator.heartbeat(Heartbeat { + job_id: 0, + worker: 7, + attempt: 1, + message_id: 1, + coordinator_now_ms: LEASE_MS, + }); + assert!(!at_deadline.changed); + assert!(at_deadline.detail.contains("expired at 30000")); + assert_eq!(coordinator.job(0).version, leased_version); + + let far_past = coordinator.complete(0, 7, 1, 2, 999_999, "objects/expired-attempt"); + assert!(!far_past.changed); + assert!(far_past.detail.contains("expired at 30000")); + assert_eq!(coordinator.job(0).version, leased_version); + + assert_eq!(coordinator.reap_expired(999_999), 1); + assert!(coordinator.lease_job(0, 8, 999_999).changed); + assert_eq!(coordinator.job(0).attempt, 2); + let reassigned_version = coordinator.job(0).version; + + let stale_heartbeat = coordinator.heartbeat(Heartbeat { + job_id: 0, + worker: 7, + attempt: 1, + message_id: 3, + coordinator_now_ms: 1_000_000, + }); + assert!(!stale_heartbeat.changed); + let stale_completion = coordinator.complete(0, 7, 1, 4, 1_000_000, "objects/stale-attempt"); + assert!(!stale_completion.changed); + assert_eq!(coordinator.job(0).version, reassigned_version); + } +} diff --git a/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/Cargo.toml b/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/Cargo.toml new file mode 100644 index 0000000..0cb6b40 --- /dev/null +++ b/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fraud-velocity-evaluation" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "warn" +pedantic = "allow" diff --git a/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/EVIDENCE.md b/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/EVIDENCE.md new file mode 100644 index 0000000..71ce5b3 --- /dev/null +++ b/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/EVIDENCE.md @@ -0,0 +1,216 @@ +# Evidence: fraud velocity blind evaluation + +Run date: 2026-07-30 +Environment: arm64 macOS 26.5.2, Rust 1.95.0, release profile inherited from the +workspace (`opt-level=3`, one codegen unit, fat LTO). + +## Outcome + +**Decision: BogKit is not a fit for this scenario on the evidence available.** + +The Redis baseline is incorrect under duplicate and late delivery. Fold offers +useful embedded transactional materialization and ranked event-time range +indexing, but the public contracts inspected do not cover the full correctness +boundary: maintained multi-rule aggregates with linked retroactive corrections, +two-ID deduplication, deletion policy, four-replica partition ownership and +transfer, or atomic coordination with durable-stream offsets. Using Fold only +as a local time index would leave the consequential system semantics custom and +unverified. ESE and ANNy are unrelated to this problem. + +The prototype independently demonstrates the intended deterministic semantics, +but it does not satisfy the full acceptance run. No BogKit correctness defect was +found because no BogKit component was forced into a job outside its documented +contract. + +## Ordered discovery and friction trail + +1. Read the root `README.md` and then all four public example manifests and + sources (`starter`, `timeseries`, `chat`, and `search`). The public path showed + durable inserts/retractions, keyed upsert/remove, aggregates, and consistent + snapshots. +2. While enumerating examples, a local zsh command used the special lowercase + variable `path`, which temporarily replaced zsh's command search path. The + command stopped after read-only output; no file changed. Rerunning with + `file_name` completed the read. +3. Built and ran the Redis-style independent-TTL reproducer before inspecting + Fold internals or selecting a component. One duplicate changed both counters + from `(1, 1)` to `(2, 2)`. A later arrival whose event time was still inside + the same minute returned `(1, 1)` because arrival-time TTLs had expired. The + design retained no contributing event list. +4. Inspected Fold's public crate, stream, keyed stream, scoring, retention, + ranked-index, tests, and manifest contracts. Positive findings: single-writer + crash-safe transactions, atomic keyed upsert/remove, deterministic + retractions, time-ordered range scans, and persistent materializations. +5. Rejected `Retain`: its documentation explicitly defines a processing-time + window stamped by transaction wall clock and calls persistence across clock + jumps best-effort. That is incompatible with event-time replay and 15-minute + late correction. +6. Rejected `TopK`: timestamp scoring produces the most recent *N records*, not + 1-minute, 10-minute, and 24-hour duration windows. +7. Considered `Ranked`/keyed ranked state as a narrow event-time index. Rejected + adoption because it would not supply deduplication across both IDs, linked + decision revisions, explanation retention, deletion policy, broker-offset + atomicity, or replica partition transfer. The critical behavior would still + be application-owned. +8. Built the dependency-free reference prototype. The first test pass caught a + real prototype deletion defect: empty account-index containers survived after + their final rows were removed. The deletion scan rejected the result. Removing + empty containers fixed it; all five behavioral tests then passed. +9. Ran formatting, strict lint, the release demonstration, and three release + benchmark rounds. The release digest was identical in every round. + +## Categorized findings + +### Baseline defects (not BogKit defects) + +- **Correctness defect:** retries double-count because neither event ID nor + merchant event ID is checked before increment. +- **Correctness defect:** independent arrival-time TTLs disagree with event-time + windows and cannot deterministically correct prior decisions. +- **Missing baseline capability:** counters alone cannot reconstruct an alert's + exact contributors. +- **Performance problem:** the baseline requires separate remote operations for + account, card, device, and IP state on the checkout path. This trial did not + connect to Redis, so no network-latency number is claimed. + +### BogKit evaluation + +- **Poor product fit:** Fold is an embedded single-writer dataflow store; the + scenario's hard boundary includes four stream consumers with partition + reassignment. +- **Missing capability:** no inspected public contract coordinates a Fold + transaction with a durable-stream offset or transfers state when a partition + moves. +- **Missing capability:** `Ranked` and `KeyedRanked` supply event-time range + scans, but no inspected operator maintains the complete multi-rule + aggregation, linked correction, deduplication, and deletion semantics. +- **API friction:** pipeline types include closure types, and the examples use + local macros where ordinary helper signatures cannot name the reader type. + That is manageable for an experienced Rust developer, but material friction + for the stated Rust-beginner persona. +- **Documentation gap:** the root guide points to generated Fold documentation + but does not surface the crucial processing-time versus event-time distinction. + The detailed `Retain` documentation itself is clear once found. +- **Actual BogKit defect:** none demonstrated. + +### Prototype evidence + +- **Correctness:** the indexed engine matched a separately implemented naive + scan for every latest decision in the demonstration fixture. +- **Deduplication:** 10 deliveries became 9 events; the duplicate changed no + state or decision record. +- **Late data:** late event 8 generated 6 linked corrections. +- **Replay:** uninterrupted and normal close/reopen paths produced 3,311 + byte-identical decision bytes. No crash or torn-write behavior was tested. +- **Explanations:** all 15 emitted alert outcomes were internally + reconstructable from retained event records. The independent naïve reference + validates latest decisions, not every historical correction revision. +- **Deletion:** 4 events owned by account 100 had their account and card keys + scrubbed in memory; the audit captured both identifiers and checked the + retained event rows plus account/card indexes. A retained event's shared + device/IP counts were unchanged, and retained alerts reconstructed. +- **Determinism:** the demonstration digest was `2a763be27500e742`. + +### Prototype limits + +- Persistence is an append-only fixture ledger with one sync per accepted event, + not an optimized production log. +- Customer deletion is verified for in-memory retained/indexed state; durable + ledger compaction and a post-compaction disk scan are not implemented. +- Normal close/reopen replay uses one canonical delivery order. It does not + simulate a crash, torn write, four replicas, cross-partition scheduling, + reassignment, or stream-offset commits. +- `PersistentEngine` mutates memory before appending and syncing its ledger. A + write or sync error can leave memory ahead of disk, and a torn final TSV row + prevents reopening. +- The rule set is fixed and partitions amount totals by currency. It counts all + authorization attempts; production policy would need to confirm that choice. +- The benchmark uses mostly sparse keys, stores full canonical decision output + in memory, and reports a payload lower bound, not allocator overhead or + resident-set size. It is an in-memory upper bound, not a production load + result. +- The trial did not run the required 20-million-event state test or sustain load + for 30 minutes. It therefore makes no full-acceptance performance or storage + claim. + +## Decision audit + +1. **Chose canonical ordering:** arrivals by `(arrival_time, event_id)` and + event-time contributions by `(event_time, event_id)`. Rejected wall clock and + hash-map iteration because replay bytes must not depend on timing or map order. +2. **Chose two-ID deduplication:** either event ID or merchant event ID blocks a + retry; a conflicting reuse is rejected. Rejected “last write wins” because it + would silently revise the payment fact. +3. **Chose explicit revisions:** late events recompute only later canonical + events sharing an affected identifier and append a correction linked to the + previous revision. Rejected silent aggregate mutation because analysts need + an audit trail. +4. **Chose contributor IDs only on alerts:** all decisions retain counts and + totals, while alerts additionally retain exact event IDs. Rejected contributor + arrays on every non-alert because they grow storage without serving the stated + explanation requirement. +5. **Chose account/card scrubbing with shared device/IP retention:** this directly + tests the deletion boundary. Rejected wholesale event removal because that + corrupts shared aggregates. +6. **Rejected partial Fold adoption:** `Ranked` could replace the prototype's + ordered maps, but that substitution would not reduce the highest-risk custom + logic and would create an unsupported impression of end-to-end fit. +7. **Uncertainty:** a production architecture could wrap Fold with partition-local + ownership, an outbox/offset protocol, Fold's range indexes, custom + correction semantics, and deletion compaction. This trial did not build or benchmark + that larger system, so it cannot rule out such an architecture; it does show + that BogKit does not currently supply the required boundary as a small, + justified adoption. + +## Exact validation commands and observed results + +```console +$ cargo run -p fraud-velocity-evaluation -- baseline +redis baseline + duplicate event 10 changed both counts: (1, 1) -> (2, 2) + late event 11 belongs with event 10 in event time, but arrival-time TTL returned (1, 1) + no retained contribution list can explain either result +``` + +```console +$ cargo test -p fraud-velocity-evaluation +running 5 tests +test result: ok. 5 passed; 0 failed +``` + +```console +$ cargo fmt -p fraud-velocity-evaluation -- --check +# exit 0, no output +``` + +```console +$ cargo clippy -p fraud-velocity-evaluation --all-targets -- -D warnings +Finished `dev` profile [optimized + debuginfo] +# exit 0 +``` + +```console +$ cargo run --release -p fraud-velocity-evaluation -- demo +10 deliveries -> 9 unique events; 1 duplicate ignored +6 linked corrections from late event 8; 6 total corrections +15 canonical records; 15 alert explanations; digest 2a763be27500e742 +normal close/reopen replay +uninterrupted and reopened ledgers produced 3311 identical decision bytes +scrubbed 4 account-owned events and audited account/card removal; shared device/IP counts unchanged +demo: PASS +``` + +```console +$ cargo run --release -p fraud-velocity-evaluation -- benchmark 100000 3 +sparse-key latest-state naive-reference comparison: PASS (2000 unique events) +round 1: 971105 deliveries/s, p99 0.002 ms, 134 corrections, 50056 alert outcomes, digest 0fb37b9c0b65c76c +round 2: 1080871 deliveries/s, p99 0.002 ms, 134 corrections, 50056 alert outcomes, digest 0fb37b9c0b65c76c +round 3: 951640 deliveries/s, p99 0.002 ms, 134 corrections, 50056 alert outcomes, digest 0fb37b9c0b65c76c +median: 971105 deliveries/s; median p99 0.002 ms +measured payload lower bound: 39.41 MiB +``` + +The mostly sparse-key benchmark fixture includes 5% late events (up to 15 +minutes) and 1% duplicate deliveries. These short-run in-memory upper-bound +results are useful prototype evidence, not substitutes for the specified +30-minute and 20-million-event gates. diff --git a/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/README.md b/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/README.md new file mode 100644 index 0000000..f97b291 --- /dev/null +++ b/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/README.md @@ -0,0 +1,65 @@ +# Fraud velocity evaluation + +This is a dependency-free reference prototype for deterministic payment-velocity +decisions. It evaluates the existing Redis-counter design first, then exercises: + +- deduplication by event ID and merchant event ID; +- event-time windows over account, salted card fingerprint, device, and IP prefix; +- linked corrections when an event arrives late; +- canonical decision bytes across a normal close/reopen replay; +- latest decisions checked against a naïve reference and retained alert + records checked for internal reconstructability; +- customer-key scrubbing while shared device/IP contributions remain; +- a repeated release-mode benchmark with 1% duplicates and 5% late events. + +It deliberately does not use a BogKit component. Fold's durable, +transactional materializations and ranked event-time range scans are useful, +but the inspected public contracts do not provide the complete maintained +semantics for linked corrections, two-ID deduplication, deletion, partition +reassignment, and atomic broker-offset ownership that this scenario requires. + +## Data and deterministic rules + +The fixture is tab-separated and contains only the supplied fields. Card values +are already salted fingerprints; no raw card or address data is accepted. +Arrivals are ordered by `(arrival_time_ms, event_id)`. Event-time ties are ordered +by `event_id`. Windows are `(target_time - window, target_time]`, partitioned by +currency, and count every authorization attempt. The demonstration rules are: + +| Rule | Window | Alert threshold | +| --- | ---: | --- | +| account | 1 minute | count at least 3 | +| salted card fingerprint | 10 minutes | count at least 2 and amount at least 100,000 minor units | +| device | 10 minutes | count at least 4 | +| IP prefix | 24 hours | count at least 6 | + +Every alert records the rule, window, count, amount, currency, and canonical +contributing event IDs. A correction links to the immediately preceding revision. + +## Reproduce + +Run from `developer-simulation/`: + +```console +cargo run -p fraud-velocity-evaluation -- baseline +cargo test -p fraud-velocity-evaluation +cargo fmt -p fraud-velocity-evaluation -- --check +cargo clippy -p fraud-velocity-evaluation --all-targets -- -D warnings +cargo run --release -p fraud-velocity-evaluation -- demo +cargo run --release -p fraud-velocity-evaluation -- benchmark 100000 3 +``` + +The benchmark's first argument is the number of unique generated events and +its second is the number of repeated rounds. Use at least two rounds. It is a +sparse-key in-memory upper bound, checks latest state against a bounded naïve +reference before timing, reports correction and alert counts, and rejects any +digest drift between rounds. + +## Important boundary + +This is a reference boundary, not a production service. It does not implement +four-replica partition transfer, atomic stream-offset commits, live Redis network +measurements, a durable customer-deletion compaction, a 30-minute load run, or a +20-million-event state measurement. `EVIDENCE.md` records the resulting no-fit +decision without attributing those missing capabilities to a demonstrated +BogKit defect. diff --git a/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/fixtures/demo.tsv b/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/fixtures/demo.tsv new file mode 100644 index 0000000..c50397b --- /dev/null +++ b/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/fixtures/demo.tsv @@ -0,0 +1,11 @@ +# event_id event_time_ms arrival_time_ms account_id salted_card_fingerprint device_id ip_prefix merchant_event_id amount_minor currency authorization_outcome +1 100000 100000 100 500 700 900 1001 40000 USD approved +2 120000 120000 100 501 700 900 1002 30000 USD approved +3 140000 140000 100 500 701 900 1003 60000 USD approved +4 150000 150000 200 600 700 900 1004 25000 USD approved +5 160000 160000 300 601 700 900 1005 20000 USD approved +6 170000 170000 400 602 702 900 1006 15000 USD approved +7 180000 180000 200 603 700 900 1007 10000 USD approved +7 180000 181000 200 603 700 900 1007 10000 USD approved +8 110000 200000 100 500 700 900 1008 30000 USD approved +9 190000 210000 500 700 800 901 1009 5000 USD declined diff --git a/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/src/lib.rs b/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/src/lib.rs new file mode 100644 index 0000000..acfaa98 --- /dev/null +++ b/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/src/lib.rs @@ -0,0 +1,1120 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fmt::Write as _; +use std::fs::{File, OpenOptions}; +use std::hash::{Hash, Hasher}; +use std::io::Write as _; +use std::ops::Bound; +use std::path::Path; + +pub const MINUTE_MS: u64 = 60_000; +pub const MAX_LATENESS_MS: u64 = 15 * MINUTE_MS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Currency([u8; 3]); + +impl Currency { + fn parse(value: &str) -> Result { + let bytes: [u8; 3] = value + .as_bytes() + .try_into() + .map_err(|_| format!("currency must contain three ASCII bytes: {value}"))?; + if !bytes.iter().all(u8::is_ascii_uppercase) { + return Err(format!("currency must be uppercase ASCII: {value}")); + } + Ok(Self(bytes)) + } + + fn as_str(self) -> &'static str { + match &self.0 { + b"USD" => "USD", + b"EUR" => "EUR", + b"GBP" => "GBP", + _ => "UNK", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationOutcome { + Approved, + Declined, +} + +impl AuthorizationOutcome { + fn parse(value: &str) -> Result { + match value { + "approved" => Ok(Self::Approved), + "declined" => Ok(Self::Declined), + _ => Err(format!("unknown authorization outcome: {value}")), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Approved => "approved", + Self::Declined => "declined", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Event { + pub event_id: u64, + pub event_time_ms: u64, + pub arrival_time_ms: u64, + pub account_id: u64, + pub salted_card_fingerprint: u64, + pub device_id: u64, + pub ip_prefix: u64, + pub merchant_event_id: u64, + pub amount_minor: i64, + pub currency: Currency, + pub authorization_outcome: AuthorizationOutcome, +} + +impl Event { + fn parse_tsv(line: &str, line_number: usize) -> Result { + let fields: Vec<_> = line.split('\t').collect(); + if fields.len() != 11 { + return Err(format!( + "line {line_number}: expected 11 tab-separated fields, got {}", + fields.len() + )); + } + let number = |index: usize, name: &str| { + fields[index] + .parse::() + .map_err(|error| format!("line {line_number}: invalid {name}: {error}")) + }; + let amount_minor = fields[8] + .parse::() + .map_err(|error| format!("line {line_number}: invalid amount_minor: {error}"))?; + if amount_minor < 0 { + return Err(format!( + "line {line_number}: amount_minor must be non-negative" + )); + } + Ok(Self { + event_id: number(0, "event_id")?, + event_time_ms: number(1, "event_time_ms")?, + arrival_time_ms: number(2, "arrival_time_ms")?, + account_id: number(3, "account_id")?, + salted_card_fingerprint: number(4, "salted_card_fingerprint")?, + device_id: number(5, "device_id")?, + ip_prefix: number(6, "ip_prefix")?, + merchant_event_id: number(7, "merchant_event_id")?, + amount_minor, + currency: Currency::parse(fields[9])?, + authorization_outcome: AuthorizationOutcome::parse(fields[10])?, + }) + } + + fn to_tsv(&self) -> String { + format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n", + self.event_id, + self.event_time_ms, + self.arrival_time_ms, + self.account_id, + self.salted_card_fingerprint, + self.device_id, + self.ip_prefix, + self.merchant_event_id, + self.amount_minor, + self.currency.as_str(), + self.authorization_outcome.as_str() + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct EventOrder { + event_time_ms: u64, + event_id: u64, +} + +impl From<&Event> for EventOrder { + fn from(event: &Event) -> Self { + Self { + event_time_ms: event.event_time_ms, + event_id: event.event_id, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum KeyKind { + Account, + Card, + Device, + IpPrefix, +} + +impl KeyKind { + fn name(self) -> &'static str { + match self { + Self::Account => "account", + Self::Card => "card", + Self::Device => "device", + Self::IpPrefix => "ip-prefix", + } + } + + fn value(self, event: &Event) -> u64 { + match self { + Self::Account => event.account_id, + Self::Card => event.salted_card_fingerprint, + Self::Device => event.device_id, + Self::IpPrefix => event.ip_prefix, + } + } +} + +#[derive(Debug, Clone, Copy)] +struct Rule { + id: &'static str, + key_kind: KeyKind, + window_ms: u64, + minimum_count: u32, + minimum_amount_minor: i64, +} + +const RULES: [Rule; 4] = [ + Rule { + id: "account-1m-count-3", + key_kind: KeyKind::Account, + window_ms: MINUTE_MS, + minimum_count: 3, + minimum_amount_minor: 0, + }, + Rule { + id: "card-10m-amount-100000", + key_kind: KeyKind::Card, + window_ms: 10 * MINUTE_MS, + minimum_count: 2, + minimum_amount_minor: 100_000, + }, + Rule { + id: "device-10m-count-4", + key_kind: KeyKind::Device, + window_ms: 10 * MINUTE_MS, + minimum_count: 4, + minimum_amount_minor: 0, + }, + Rule { + id: "ip-prefix-24h-count-6", + key_kind: KeyKind::IpPrefix, + window_ms: 24 * 60 * MINUTE_MS, + minimum_count: 6, + minimum_amount_minor: 0, + }, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct IndexKey { + kind: KeyKind, + value: u64, + currency: Currency, +} + +impl IndexKey { + fn new(rule: Rule, event: &Event) -> Self { + Self { + kind: rule.key_kind, + value: rule.key_kind.value(event), + currency: event.currency, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuleOutcome { + rule_id: &'static str, + key_kind: KeyKind, + window_ms: u64, + count: u32, + amount_minor: i64, + currency: Currency, + alert: bool, + contributing_event_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LatestDecision { + revision: u32, + outcomes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecisionRecord { + event_id: u64, + revision: u32, + correction_of_revision: Option, + outcomes: Vec, +} + +impl DecisionRecord { + fn append_canonical(&self, output: &mut String) { + let record_kind = if self.correction_of_revision.is_some() { + 'C' + } else { + 'I' + }; + write!(output, "{record_kind}|{}|{}|", self.event_id, self.revision) + .expect("writing to String cannot fail"); + match self.correction_of_revision { + Some(revision) => { + write!(output, "{}:{revision}", self.event_id) + .expect("writing to String cannot fail"); + } + None => output.push('-'), + } + for outcome in &self.outcomes { + write!( + output, + "|{}:{}:{}:{}:{}:{}:{}:", + outcome.rule_id, + outcome.key_kind.name(), + outcome.window_ms, + outcome.count, + outcome.amount_minor, + outcome.currency.as_str(), + u8::from(outcome.alert) + ) + .expect("writing to String cannot fail"); + for (index, event_id) in outcome.contributing_event_ids.iter().enumerate() { + if index > 0 { + output.push(','); + } + write!(output, "{event_id}").expect("writing to String cannot fail"); + } + } + output.push('\n'); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IngestResult { + Accepted { correction_count: usize }, + ExactDuplicate, + ConflictingDuplicate, +} + +#[derive(Debug)] +pub struct DeletionReceipt { + account_id: u64, + event_ids: BTreeSet, + card_fingerprints: BTreeSet, +} + +impl DeletionReceipt { + pub fn event_count(&self) -> usize { + self.event_ids.len() + } +} + +#[derive(Default)] +pub struct Engine { + events: BTreeMap, + event_order_by_id: HashMap, + event_id_by_merchant_id: HashMap, + indexes: HashMap>, + latest: HashMap, + records: Vec, +} + +impl Engine { + pub fn ingest(&mut self, event: Event) -> IngestResult { + if let Some(order) = self.event_order_by_id.get(&event.event_id) { + let existing = self + .events + .get(order) + .expect("event ID index must point to an event"); + return if existing == &event + || (existing.merchant_event_id == event.merchant_event_id + && existing.event_time_ms == event.event_time_ms + && existing.account_id == event.account_id + && existing.salted_card_fingerprint == event.salted_card_fingerprint + && existing.device_id == event.device_id + && existing.ip_prefix == event.ip_prefix + && existing.amount_minor == event.amount_minor + && existing.currency == event.currency + && existing.authorization_outcome == event.authorization_outcome) + { + IngestResult::ExactDuplicate + } else { + IngestResult::ConflictingDuplicate + }; + } + if let Some(existing_event_id) = self.event_id_by_merchant_id.get(&event.merchant_event_id) + { + return if self + .events + .get( + self.event_order_by_id + .get(existing_event_id) + .expect("merchant ID index must point to an event ID"), + ) + .is_some_and(|existing| { + existing.event_time_ms == event.event_time_ms + && existing.account_id == event.account_id + && existing.salted_card_fingerprint == event.salted_card_fingerprint + && existing.device_id == event.device_id + && existing.ip_prefix == event.ip_prefix + && existing.amount_minor == event.amount_minor + && existing.currency == event.currency + && existing.authorization_outcome == event.authorization_outcome + }) { + IngestResult::ExactDuplicate + } else { + IngestResult::ConflictingDuplicate + }; + } + + let order = EventOrder::from(&event); + let mut affected = BTreeSet::new(); + for rule in RULES { + let key = IndexKey::new(rule, &event); + if key.value == 0 { + continue; + } + if let Some(index) = self.indexes.get(&key) { + let end_time = event.event_time_ms.saturating_add(rule.window_ms); + let end = EventOrder { + event_time_ms: end_time, + event_id: 0, + }; + for (later_order, _) in index.range((Bound::Excluded(order), Bound::Excluded(end))) + { + affected.insert(*later_order); + } + } + } + + self.event_order_by_id.insert(event.event_id, order); + self.event_id_by_merchant_id + .insert(event.merchant_event_id, event.event_id); + for rule in RULES { + let key = IndexKey::new(rule, &event); + if key.value != 0 { + self.indexes + .entry(key) + .or_default() + .insert(order, event.amount_minor); + } + } + let event_id = event.event_id; + self.events.insert(order, event); + + let outcomes = self.outcomes_from_indexes(order); + self.latest.insert( + event_id, + LatestDecision { + revision: 0, + outcomes: outcomes.clone(), + }, + ); + self.records.push(DecisionRecord { + event_id, + revision: 0, + correction_of_revision: None, + outcomes, + }); + + let mut correction_count = 0; + for target_order in affected { + let target_id = target_order.event_id; + let corrected = self.outcomes_from_indexes(target_order); + let latest = self + .latest + .get_mut(&target_id) + .expect("affected event must already have a decision"); + if latest.outcomes != corrected { + let previous_revision = latest.revision; + latest.revision += 1; + latest.outcomes = corrected.clone(); + self.records.push(DecisionRecord { + event_id: target_id, + revision: latest.revision, + correction_of_revision: Some(previous_revision), + outcomes: corrected, + }); + correction_count += 1; + } + } + + IngestResult::Accepted { correction_count } + } + + fn outcomes_from_indexes(&self, target_order: EventOrder) -> Vec { + let target = self + .events + .get(&target_order) + .expect("target order must point to an event"); + RULES + .into_iter() + .map(|rule| { + let key = IndexKey::new(rule, target); + let mut count = 0_u32; + let mut amount_minor = 0_i64; + let mut contributing_event_ids = Vec::new(); + if key.value != 0 + && let Some(index) = self.indexes.get(&key) + { + let lower = lower_bound(target_order.event_time_ms, rule.window_ms); + for (order, amount) in index.range((lower, Bound::Included(target_order))) { + count = count.saturating_add(1); + amount_minor = amount_minor.saturating_add(*amount); + contributing_event_ids.push(order.event_id); + } + } + let alert = + count >= rule.minimum_count && amount_minor >= rule.minimum_amount_minor; + if !alert { + contributing_event_ids.clear(); + } + RuleOutcome { + rule_id: rule.id, + key_kind: rule.key_kind, + window_ms: rule.window_ms, + count, + amount_minor, + currency: target.currency, + alert, + contributing_event_ids, + } + }) + .collect() + } + + fn outcomes_from_naive_scan(&self, target_order: EventOrder) -> Vec { + let target = self + .events + .get(&target_order) + .expect("target order must point to an event"); + RULES + .into_iter() + .map(|rule| { + let target_key = rule.key_kind.value(target); + let mut contributing_event_ids = Vec::new(); + let mut amount_minor = 0_i64; + for (order, candidate) in self.events.range(..=target_order) { + if candidate.currency == target.currency + && rule.key_kind.value(candidate) == target_key + && target_key != 0 + && in_window( + order.event_time_ms, + target_order.event_time_ms, + rule.window_ms, + ) + { + contributing_event_ids.push(candidate.event_id); + amount_minor = amount_minor.saturating_add(candidate.amount_minor); + } + } + let count = u32::try_from(contributing_event_ids.len()).unwrap_or(u32::MAX); + let alert = + count >= rule.minimum_count && amount_minor >= rule.minimum_amount_minor; + if !alert { + contributing_event_ids.clear(); + } + RuleOutcome { + rule_id: rule.id, + key_kind: rule.key_kind, + window_ms: rule.window_ms, + count, + amount_minor, + currency: target.currency, + alert, + contributing_event_ids, + } + }) + .collect() + } + + pub fn assert_matches_reference(&self) -> Result<(), String> { + for order in self.events.keys().copied() { + let target_id = order.event_id; + let optimized = &self + .latest + .get(&target_id) + .ok_or_else(|| format!("event {target_id} has no latest decision"))? + .outcomes; + let reference = self.outcomes_from_naive_scan(order); + if optimized != &reference { + return Err(format!( + "event {target_id} differs from the independent naive reference" + )); + } + } + Ok(()) + } + + pub fn decision_bytes(&self) -> Vec { + let mut output = String::new(); + for record in &self.records { + record.append_canonical(&mut output); + } + output.into_bytes() + } + + pub fn record_count(&self) -> usize { + self.records.len() + } + + pub fn event_count(&self) -> usize { + self.events.len() + } + + pub fn alert_count(&self) -> usize { + self.records + .iter() + .flat_map(|record| &record.outcomes) + .filter(|outcome| outcome.alert) + .count() + } + + pub fn correction_count(&self) -> usize { + self.records + .iter() + .filter(|record| record.correction_of_revision.is_some()) + .count() + } + + pub fn digest(&self) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.decision_bytes().hash(&mut hasher); + hasher.finish() + } + + pub fn latest_rule_count(&self, event_id: u64, rule_id: &str) -> Option { + self.latest.get(&event_id).and_then(|decision| { + decision + .outcomes + .iter() + .find(|outcome| outcome.rule_id == rule_id) + .map(|outcome| outcome.count) + }) + } + + pub fn delete_customer(&mut self, account_id: u64) -> Result { + let deleted_orders: Vec<_> = self + .events + .iter() + .filter_map(|(order, event)| (event.account_id == account_id).then_some(*order)) + .collect(); + let deleted_ids: BTreeSet<_> = deleted_orders.iter().map(|order| order.event_id).collect(); + let deleted_cards: BTreeSet<_> = deleted_orders + .iter() + .filter_map(|order| self.events.get(order)) + .map(|event| event.salted_card_fingerprint) + .collect(); + let receipt = DeletionReceipt { + account_id, + event_ids: deleted_ids.clone(), + card_fingerprints: deleted_cards, + }; + if deleted_ids.is_empty() { + return Ok(receipt); + } + + for order in &deleted_orders { + let event = self + .events + .get(order) + .expect("deletion order must point to event") + .clone(); + for rule in RULES { + if matches!(rule.key_kind, KeyKind::Account | KeyKind::Card) { + let key = IndexKey::new(rule, &event); + if let Some(index) = self.indexes.get_mut(&key) { + index.remove(order); + } + } + } + let event = self + .events + .get_mut(order) + .expect("deletion order must still point to event"); + event.account_id = 0; + event.salted_card_fingerprint = 0; + } + self.indexes.retain(|_, index| !index.is_empty()); + + self.latest + .retain(|event_id, _| !deleted_ids.contains(event_id)); + self.records + .retain(|record| !deleted_ids.contains(&record.event_id)); + for record in &mut self.records { + record.outcomes.retain(|outcome| { + !matches!(outcome.key_kind, KeyKind::Account | KeyKind::Card) + || !outcome + .contributing_event_ids + .iter() + .any(|event_id| deleted_ids.contains(event_id)) + }); + } + + let retained_orders: Vec<_> = self + .events + .keys() + .copied() + .filter(|order| !deleted_ids.contains(&order.event_id)) + .collect(); + for order in retained_orders { + let corrected = self.outcomes_from_indexes(order); + let latest = self + .latest + .get_mut(&order.event_id) + .expect("retained event must have a latest decision"); + if latest.outcomes != corrected { + let previous_revision = latest.revision; + latest.revision += 1; + latest.outcomes = corrected.clone(); + self.records.push(DecisionRecord { + event_id: order.event_id, + revision: latest.revision, + correction_of_revision: Some(previous_revision), + outcomes: corrected, + }); + } + } + + self.scan_customer(&receipt)?; + self.reconstruct_all_alerts()?; + Ok(receipt) + } + + pub fn scan_customer(&self, receipt: &DeletionReceipt) -> Result<(), String> { + if self + .events + .values() + .any(|event| event.account_id == receipt.account_id) + { + return Err(format!( + "account {} remains in retained events", + receipt.account_id + )); + } + if self + .indexes + .keys() + .any(|key| key.kind == KeyKind::Account && key.value == receipt.account_id) + { + return Err(format!( + "account {} remains in an account index", + receipt.account_id + )); + } + for event_id in &receipt.event_ids { + let order = self + .event_order_by_id + .get(event_id) + .ok_or_else(|| format!("deleted event {event_id} lost its audit row"))?; + let event = self + .events + .get(order) + .ok_or_else(|| format!("deleted event {event_id} lost its retained record"))?; + if event.account_id != 0 || event.salted_card_fingerprint != 0 { + return Err(format!( + "deleted event {event_id} retains an account or card identifier" + )); + } + for (key, index) in &self.indexes { + if matches!(key.kind, KeyKind::Account | KeyKind::Card) + && (key.kind == KeyKind::Account + || receipt.card_fingerprints.contains(&key.value)) + && index.contains_key(order) + { + return Err(format!( + "deleted event {event_id} remains in a {} index", + key.kind.name() + )); + } + } + } + Ok(()) + } + + pub fn reconstruct_all_alerts(&self) -> Result<(), String> { + for record in &self.records { + let target_order = self + .event_order_by_id + .get(&record.event_id) + .ok_or_else(|| format!("missing target event {}", record.event_id))?; + let target = self + .events + .get(target_order) + .ok_or_else(|| format!("missing target record {}", record.event_id))?; + for outcome in &record.outcomes { + if !outcome.alert { + continue; + } + let rule = RULES + .into_iter() + .find(|rule| rule.id == outcome.rule_id) + .ok_or_else(|| format!("unknown retained rule {}", outcome.rule_id))?; + let target_key = rule.key_kind.value(target); + let mut amount_minor = 0_i64; + for event_id in &outcome.contributing_event_ids { + let contribution_order = self + .event_order_by_id + .get(event_id) + .ok_or_else(|| format!("missing contribution event {event_id}"))?; + let contribution = self.events.get(contribution_order).ok_or_else(|| { + format!("missing retained contribution record {event_id}") + })?; + if *contribution_order > *target_order + || contribution.currency != outcome.currency + || rule.key_kind.value(contribution) != target_key + || !in_window( + contribution.event_time_ms, + target.event_time_ms, + rule.window_ms, + ) + { + return Err(format!( + "event {event_id} cannot reconstruct {} for target {}", + rule.id, record.event_id + )); + } + amount_minor = amount_minor.saturating_add(contribution.amount_minor); + } + let count = u32::try_from(outcome.contributing_event_ids.len()).unwrap_or(u32::MAX); + if count != outcome.count || amount_minor != outcome.amount_minor { + return Err(format!( + "retained explanation mismatch for event {} rule {}", + record.event_id, rule.id + )); + } + } + } + Ok(()) + } + + pub fn approximate_payload_bytes(&self) -> usize { + let event_payload = self.events.len() * std::mem::size_of::(); + let index_rows: usize = self.indexes.values().map(BTreeMap::len).sum(); + let index_payload = + index_rows * (std::mem::size_of::() + std::mem::size_of::()); + event_payload + index_payload + self.decision_bytes().len() + } +} + +fn lower_bound(target_time_ms: u64, window_ms: u64) -> Bound { + if target_time_ms < window_ms { + Bound::Unbounded + } else { + Bound::Excluded(EventOrder { + event_time_ms: target_time_ms - window_ms, + event_id: u64::MAX, + }) + } +} + +fn in_window(candidate_time_ms: u64, target_time_ms: u64, window_ms: u64) -> bool { + candidate_time_ms <= target_time_ms + && target_time_ms.saturating_sub(candidate_time_ms) < window_ms +} + +pub fn load_fixture(path: &Path) -> Result, String> { + let contents = std::fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let mut events = Vec::new(); + for (line_index, line) in contents.lines().enumerate() { + if line.is_empty() || line.starts_with('#') { + continue; + } + events.push(Event::parse_tsv(line, line_index + 1)?); + } + events.sort_by(|left, right| { + left.arrival_time_ms + .cmp(&right.arrival_time_ms) + .then_with(|| left.event_id.cmp(&right.event_id)) + }); + Ok(events) +} + +pub struct PersistentEngine { + engine: Engine, + ledger: File, +} + +impl PersistentEngine { + pub fn open(directory: &Path) -> Result { + std::fs::create_dir_all(directory) + .map_err(|error| format!("failed to create {}: {error}", directory.display()))?; + let ledger_path = directory.join("accepted-events.tsv"); + let mut engine = Engine::default(); + if ledger_path.exists() { + for event in load_fixture(&ledger_path)? { + if !matches!(engine.ingest(event), IngestResult::Accepted { .. }) { + return Err("persisted ledger contains a duplicate".to_string()); + } + } + } + let ledger = OpenOptions::new() + .create(true) + .append(true) + .open(&ledger_path) + .map_err(|error| format!("failed to open {}: {error}", ledger_path.display()))?; + Ok(Self { engine, ledger }) + } + + pub fn ingest(&mut self, event: Event) -> Result { + let result = self.engine.ingest(event.clone()); + if matches!(result, IngestResult::Accepted { .. }) { + self.ledger + .write_all(event.to_tsv().as_bytes()) + .map_err(|error| format!("failed to append event ledger: {error}"))?; + self.ledger + .sync_data() + .map_err(|error| format!("failed to sync event ledger: {error}"))?; + } + Ok(result) + } + + pub fn decision_bytes(&self) -> Vec { + self.engine.decision_bytes() + } +} + +#[derive(Default)] +pub struct RedisStyleBaseline { + account: HashMap, + card: HashMap, +} + +impl RedisStyleBaseline { + fn apply(&mut self, event: &Event) -> (u64, u64) { + let account = increment_with_independent_ttl( + &mut self.account, + event.account_id, + event.arrival_time_ms, + ); + let card = increment_with_independent_ttl( + &mut self.card, + event.salted_card_fingerprint, + event.arrival_time_ms, + ); + (account, card) + } +} + +fn increment_with_independent_ttl( + counters: &mut HashMap, + key: u64, + arrival_time_ms: u64, +) -> u64 { + let entry = counters + .entry(key) + .or_insert((0, arrival_time_ms + MINUTE_MS)); + if arrival_time_ms >= entry.1 { + *entry = (0, arrival_time_ms + MINUTE_MS); + } + entry.0 += 1; + entry.0 +} + +pub fn baseline_observations() -> Vec { + let original = Event { + event_id: 10, + event_time_ms: 10_000, + arrival_time_ms: 10_000, + account_id: 7, + salted_card_fingerprint: 9, + device_id: 11, + ip_prefix: 12, + merchant_event_id: 1010, + amount_minor: 10_000, + currency: Currency(*b"USD"), + authorization_outcome: AuthorizationOutcome::Approved, + }; + let duplicate = Event { + arrival_time_ms: 11_000, + ..original.clone() + }; + let late = Event { + event_id: 11, + event_time_ms: 20_000, + arrival_time_ms: 80_000, + merchant_event_id: 1011, + ..original.clone() + }; + let mut baseline = RedisStyleBaseline::default(); + let first = baseline.apply(&original); + let after_duplicate = baseline.apply(&duplicate); + let after_late = baseline.apply(&late); + + assert_eq!(first, (1, 1)); + assert_eq!(after_duplicate, (2, 2)); + assert_eq!(after_late, (1, 1)); + assert!(late.event_time_ms - original.event_time_ms < MINUTE_MS); + + vec![ + format!("duplicate event 10 changed both counts: {first:?} -> {after_duplicate:?}"), + format!( + "late event 11 belongs with event 10 in event time, but arrival-time TTL returned {after_late:?}" + ), + "no retained contribution list can explain either result".to_string(), + ] +} + +pub fn generated_event(unique_index: u64) -> Event { + let nominal_time = unique_index.saturating_mul(40); + let is_late = unique_index % 20 == 19; + let delay = if is_late { + ((unique_index.wrapping_mul(7_919) % MAX_LATENESS_MS) + 1).min(nominal_time) + } else { + 0 + }; + Event { + event_id: unique_index + 1, + event_time_ms: nominal_time - delay, + arrival_time_ms: nominal_time, + account_id: 1 + unique_index % 100_000, + salted_card_fingerprint: 1 + unique_index.wrapping_mul(17) % 200_000, + device_id: 1 + unique_index.wrapping_mul(31) % 50_000, + ip_prefix: 1 + unique_index.wrapping_mul(43) % 10_000, + merchant_event_id: 1_000_000_000 + unique_index, + amount_minor: 1_000 + i64::try_from(unique_index % 90_000).unwrap_or(0), + currency: Currency(*b"USD"), + authorization_outcome: if unique_index.is_multiple_of(10) { + AuthorizationOutcome::Declined + } else { + AuthorizationOutcome::Approved + }, + } +} + +pub fn percentile_nanos(samples: &mut [u64], percentile: usize) -> u64 { + samples.sort_unstable(); + let rank = samples + .len() + .saturating_mul(percentile) + .div_ceil(100) + .saturating_sub(1); + samples[rank.min(samples.len().saturating_sub(1))] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> Vec { + load_fixture( + &Path::new(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("demo.tsv"), + ) + .expect("fixture should parse") + } + + #[test] + fn baseline_reproducer_exposes_three_defects() { + let observations = baseline_observations(); + assert_eq!(observations.len(), 3); + } + + #[test] + fn fixture_matches_naive_reference_and_duplicates_are_inert() { + let mut engine = Engine::default(); + let mut duplicate_count = 0; + for event in fixture() { + match engine.ingest(event) { + IngestResult::Accepted { .. } => {} + IngestResult::ExactDuplicate => duplicate_count += 1, + IngestResult::ConflictingDuplicate => panic!("unexpected conflict"), + } + } + assert_eq!(duplicate_count, 1); + assert_eq!(engine.event_count(), 9); + engine + .assert_matches_reference() + .expect("indexed results should equal independent scan"); + engine + .reconstruct_all_alerts() + .expect("every alert should reconstruct"); + } + + #[test] + fn late_event_emits_linked_corrections() { + let mut engine = Engine::default(); + let mut late_corrections = 0; + for event in fixture() { + let is_late = event.event_id == 8; + if let IngestResult::Accepted { correction_count } = engine.ingest(event) + && is_late + { + late_corrections = correction_count; + } + } + assert!(late_corrections > 0); + assert!(engine.correction_count() >= late_corrections); + let output = String::from_utf8(engine.decision_bytes()).expect("ASCII decision records"); + assert!(output.lines().any(|line| line.starts_with("C|"))); + } + + #[test] + fn normal_close_reopen_replay_is_byte_identical() { + let root = std::env::temp_dir().join(format!( + "fraud-velocity-restart-test-{}", + std::process::id() + )); + let direct_path = root.join("direct"); + let restarted_path = root.join("restarted"); + let _ = std::fs::remove_dir_all(&root); + let events = fixture(); + + let direct = { + let mut persistent = PersistentEngine::open(&direct_path).expect("open direct ledger"); + for event in &events { + persistent.ingest(event.clone()).expect("direct ingest"); + } + persistent.decision_bytes() + }; + let restarted = { + let split = events.len() / 2; + { + let mut persistent = + PersistentEngine::open(&restarted_path).expect("open first ledger"); + for event in &events[..split] { + persistent.ingest(event.clone()).expect("first ingest"); + } + } + let mut persistent = PersistentEngine::open(&restarted_path).expect("reopen ledger"); + for event in &events[split..] { + persistent.ingest(event.clone()).expect("second ingest"); + } + persistent.decision_bytes() + }; + assert_eq!(direct, restarted); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn customer_deletion_scrubs_owned_keys_and_preserves_shared_windows() { + let mut engine = Engine::default(); + for event in fixture() { + engine.ingest(event); + } + let before_device = engine.latest_rule_count(7, "device-10m-count-4"); + let before_ip = engine.latest_rule_count(7, "ip-prefix-24h-count-6"); + let receipt = engine.delete_customer(100).expect("customer deletion"); + assert_eq!(receipt.event_count(), 4); + assert_eq!( + before_device, + engine.latest_rule_count(7, "device-10m-count-4") + ); + assert_eq!( + before_ip, + engine.latest_rule_count(7, "ip-prefix-24h-count-6") + ); + engine + .scan_customer(&receipt) + .expect("account and card scan"); + engine + .reconstruct_all_alerts() + .expect("retained alerts reconstruct"); + } +} diff --git a/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/src/main.rs b/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/src/main.rs new file mode 100644 index 0000000..c6f64a8 --- /dev/null +++ b/developer-simulation/runs/2026-07-30--fraud-velocity-evaluation/src/main.rs @@ -0,0 +1,229 @@ +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use fraud_velocity_evaluation::{ + Engine, IngestResult, PersistentEngine, baseline_observations, generated_event, load_fixture, + percentile_nanos, +}; + +fn fixture_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join("demo.tsv") +} + +fn run_baseline() { + println!("redis baseline"); + for observation in baseline_observations() { + println!(" {observation}"); + } +} + +fn run_demo() -> Result<(), String> { + run_baseline(); + let events = load_fixture(&fixture_path())?; + let mut engine = Engine::default(); + let mut duplicates = 0; + let mut corrections_from_late_event = 0; + for event in &events { + match engine.ingest(event.clone()) { + IngestResult::Accepted { correction_count } => { + if event.event_id == 8 { + corrections_from_late_event = correction_count; + } + } + IngestResult::ExactDuplicate => duplicates += 1, + IngestResult::ConflictingDuplicate => { + return Err(format!("fixture event {} conflicts", event.event_id)); + } + } + } + engine.assert_matches_reference()?; + engine.reconstruct_all_alerts()?; + println!("deterministic fixture"); + println!( + " {} deliveries -> {} unique events; {} duplicate ignored", + events.len(), + engine.event_count(), + duplicates + ); + println!( + " {} linked corrections from late event 8; {} total corrections", + corrections_from_late_event, + engine.correction_count() + ); + println!( + " {} canonical records; {} alert explanations; digest {:016x}", + engine.record_count(), + engine.alert_count(), + engine.digest() + ); + + let recovery_root = + std::env::temp_dir().join(format!("fraud-velocity-demo-{}", std::process::id())); + let direct_path = recovery_root.join("direct"); + let restarted_path = recovery_root.join("restarted"); + let _ = std::fs::remove_dir_all(&recovery_root); + let direct = { + let mut persistent = PersistentEngine::open(&direct_path)?; + for event in &events { + persistent.ingest(event.clone())?; + } + persistent.decision_bytes() + }; + let restarted = { + let split = events.len() / 2; + { + let mut persistent = PersistentEngine::open(&restarted_path)?; + for event in &events[..split] { + persistent.ingest(event.clone())?; + } + } + let mut persistent = PersistentEngine::open(&restarted_path)?; + for event in &events[split..] { + persistent.ingest(event.clone())?; + } + persistent.decision_bytes() + }; + if direct != restarted { + return Err("restart changed canonical decision bytes".to_string()); + } + println!( + "normal close/reopen replay\n uninterrupted and reopened ledgers produced {} identical decision bytes", + direct.len() + ); + let _ = std::fs::remove_dir_all(&recovery_root); + + let before_device = engine.latest_rule_count(7, "device-10m-count-4"); + let before_ip = engine.latest_rule_count(7, "ip-prefix-24h-count-6"); + let receipt = engine.delete_customer(100)?; + if before_device != engine.latest_rule_count(7, "device-10m-count-4") + || before_ip != engine.latest_rule_count(7, "ip-prefix-24h-count-6") + { + return Err("customer deletion corrupted shared device/IP counts".to_string()); + } + engine.scan_customer(&receipt)?; + engine.reconstruct_all_alerts()?; + println!( + "customer deletion\n scrubbed {} account-owned events and audited account/card removal; shared device/IP counts unchanged; retained alerts reconstruct", + receipt.event_count() + ); + println!("demo: PASS"); + Ok(()) +} + +fn run_benchmark(unique_events: usize, rounds: usize) -> Result<(), String> { + if unique_events == 0 || rounds < 2 { + return Err("benchmark needs at least one event and two rounds".to_string()); + } + println!( + "sparse-key in-memory upper-bound benchmark: {unique_events} unique events + 1% duplicate deliveries, {rounds} rounds" + ); + let reference_probe_events = unique_events.min(2_000); + let mut reference_probe = Engine::default(); + for index in 0..reference_probe_events { + let event = generated_event(index as u64); + reference_probe.ingest(event.clone()); + if index % 100 == 99 { + reference_probe.ingest(event); + } + } + reference_probe.assert_matches_reference()?; + println!( + " sparse-key latest-state naive-reference comparison: PASS ({reference_probe_events} unique events)" + ); + let mut throughputs = Vec::with_capacity(rounds); + let mut p99_values = Vec::with_capacity(rounds); + let mut final_digest = None; + let mut payload_bytes = 0; + for round in 0..rounds { + let mut engine = Engine::default(); + let mut latencies = Vec::with_capacity(unique_events + unique_events / 100 + 1); + let wall_start = Instant::now(); + for index in 0..unique_events { + let event = generated_event(index as u64); + let event_start = Instant::now(); + engine.ingest(event.clone()); + latencies.push(u64::try_from(event_start.elapsed().as_nanos()).unwrap_or(u64::MAX)); + if index % 100 == 99 { + let duplicate_start = Instant::now(); + let result = engine.ingest(event); + latencies + .push(u64::try_from(duplicate_start.elapsed().as_nanos()).unwrap_or(u64::MAX)); + if result != IngestResult::ExactDuplicate { + return Err("benchmark duplicate altered state".to_string()); + } + } + } + let elapsed = wall_start.elapsed(); + engine.reconstruct_all_alerts()?; + let deliveries = latencies.len(); + let throughput = deliveries as f64 / elapsed.as_secs_f64(); + let p99 = percentile_nanos(&mut latencies, 99); + let digest = engine.digest(); + if let Some(expected) = final_digest + && expected != digest + { + return Err("benchmark rounds produced different decision digests".to_string()); + } + final_digest = Some(digest); + payload_bytes = engine.approximate_payload_bytes(); + throughputs.push(throughput); + p99_values.push(p99); + println!( + " round {}: {:.0} deliveries/s, p99 {:.3} ms, {} corrections, {} alert outcomes, digest {digest:016x}", + round + 1, + throughput, + p99 as f64 / 1_000_000.0, + engine.correction_count(), + engine.alert_count() + ); + } + throughputs.sort_by(f64::total_cmp); + p99_values.sort_unstable(); + println!( + " median: {:.0} deliveries/s; median p99 {:.3} ms; measured payload lower bound {:.2} MiB", + throughputs[rounds / 2], + p99_values[rounds / 2] as f64 / 1_000_000.0, + payload_bytes as f64 / (1024.0 * 1024.0) + ); + println!( + " scope: mixed stream includes 5% late events up to 15 minutes and 1% duplicates; this is not a 30-minute/20-million-event acceptance run" + ); + Ok(()) +} + +fn parse_usize(value: Option, default: usize, name: &str) -> Result { + value.map_or(Ok(default), |value| { + value + .parse() + .map_err(|error| format!("invalid {name}: {error}")) + }) +} + +fn run() -> Result<(), String> { + let mut args = std::env::args().skip(1); + match args.next().as_deref() { + Some("baseline") => { + run_baseline(); + Ok(()) + } + Some("demo") => run_demo(), + Some("benchmark") => { + let events = parse_usize(args.next(), 100_000, "event count")?; + let rounds = parse_usize(args.next(), 3, "round count")?; + run_benchmark(events, rounds) + } + _ => Err( + "usage: fraud-velocity-evaluation " + .to_string(), + ), + } +} + +fn main() { + if let Err(error) = run() { + eprintln!("error: {error}"); + std::process::exit(1); + } +} diff --git a/developer-simulation/runs/2026-07-31--homecare-gap-fill/Cargo.toml b/developer-simulation/runs/2026-07-31--homecare-gap-fill/Cargo.toml new file mode 100644 index 0000000..f599574 --- /dev/null +++ b/developer-simulation/runs/2026-07-31--homecare-gap-fill/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "homecare-gap-fill" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +chrono = { version = "0.4", default-features = false, features = ["std"] } +fold = { path = "../../../fold" } +serde = { version = "1", features = ["derive"] } diff --git a/developer-simulation/runs/2026-07-31--homecare-gap-fill/EVIDENCE.md b/developer-simulation/runs/2026-07-31--homecare-gap-fill/EVIDENCE.md new file mode 100644 index 0000000..8dba19b --- /dev/null +++ b/developer-simulation/runs/2026-07-31--homecare-gap-fill/EVIDENCE.md @@ -0,0 +1,174 @@ +# Evidence + +## Ordered discovery and friction + +1. Read the repository `README.md` first. It described Fold as an incremental, + durable stream framework and named starter, time-series, chat, and search + examples. +2. Read the four public examples in that order: starter, time-series, chat, + search. Starter established durable transactions and retractions; + time-series showed keyed aggregates; chat showed one writer and consistent + snapshots; search showed keyed upsert/remove semantics. +3. Inspected only the public Fold keyed-stream, table, and pipeline APIs needed + to determine whether the component could represent mutable input and durable + count views. +4. Evaluated the baseline before selecting a component. A deterministic + full-rescan greedy scheduler met fill and urgent coverage, but the sampled + cancellation changes preserved only 98.2940% of unaffected assignments on + average. That misses the 99.5% requirement and motivates a local change path. +5. Selected Fold narrowly for durable keyed state and transactional materialized + counts. Scheduling itself stayed in ordinary Rust. ESE and ANNy were rejected + as no-fit. +6. First dependency resolution attempt failed because the sandbox could not + resolve `index.crates.io`. After network access was approved, dependencies + resolved. Subsequent checks ran offline. +7. The first compile exposed a generic associated-reader type that could not be + destructured. Replacing anonymous closures with named functions gave the + Fold pipeline a concrete type; no BogKit source was changed. +8. The first successful workload over-correlated region and certification and + made most unfilled visits structurally impossible. Randomizing certification + independently and lowering the total hour cap created meaningful capacity + pressure: cancellations now free capacity for the incremental path. +9. Skeptical review found that the validator returned `TRAVEL_CONFLICT` whenever + all earlier filters left a candidate, without proving travel was the blocker. + A one-caregiver reproducer was incorrectly accepted. The validator now checks + travel independently, errors when any caregiver is eligible, and has a + regression test. +10. Review also rejected the initial “partial fit” label. The prototype has no + SQLite adapter, change feed, rebuild policy, idempotent handoff, or + cross-store transaction, so the scenario remains no-fit for the required + SQLite-authoritative boundary. + +## Exact commands and observed results + +All commands were run from this crate directory. + +### Dependency-resolution failure + +```console +cargo test --all-targets +``` + +Observed: failed before compilation because `index.crates.io` could not be +resolved. Retried with approved network access; Cargo downloaded dependencies +and then reported the concrete Fold reader type error described above. + +### Tests + +```console +cargo test --all-targets --offline +``` + +Observed after review fixes: exit 0; 5 passed, 0 failed. Coverage includes +explicit-offset instant arithmetic around two DST transitions, rejection of +offset-free local times, the spurious-travel-reason regression, deterministic +scheduling, cancellation stability, independent validation, and Fold +round-trip recovery with materialized count checks. + +### Formatting + +```console +cargo fmt --check +``` + +Observed: exit 0; no output. + +### Linting + +```console +cargo clippy --all-targets --offline -- -D warnings +``` + +Observed: exit 0; no warnings in the prototype or its targets. + +### Release demo + +```console +cargo run --release --offline +``` + +Observed: exit 0. One measured run: + +```text +DATASET label=10%-representative caregivers=2000 visits=12000 horizon_days=14 generated_ms=1.959 +BASELINE initial_ms=133.788 sampled_changes=12 sampled_p95_ms=116.540 sampled_mean_preservation_pct=98.2940 final_rescan_ms=103.811 filled=10000 urgent_filled=2761/2913 +INCREMENTAL initial_ms=485.727 burst_changes=200 burst_ms=900.334 p95_ms=5.363 throughput_changes_per_s=222.1 preservation_pct=100.0000 filled=10000 urgent_filled=2761/2913 +VALIDATOR status=ok constraint_violations=0 active_visits=11800 outcomes=11800 unfilled_reason_codes={"HOUR_LIMIT": 1235, "NO_CERTIFICATION": 57, "NO_REGION_COVERAGE": 0, "OUTSIDE_AVAILABILITY": 0, "REQUIRED_REST": 508, "TRAVEL_CONFLICT": 0} +REPLAY status=deterministic digest=3a9719e0c8654377 replay_ms=505.919 +RESTART status=ok recovery_ms=47.200 caregivers=2000 active_visits=11800 assignments=10000 unfilled=1800 +CRASH_RESTART status=ok child_status=signal: 6 (SIGABRT) recovery_ms=50.553 committed_visit=28 canceled_visits=201 +``` + +The latency above includes a Fold transaction and explicit checkpoint for every +change. Two reviewer reruns measured baseline sampled p95 at 102.118 and +113.509 ms and incremental p95 at 5.517 and 5.244 ms. All three runs reproduced +digest `3a9719e0c8654377`, zero validator violations, the same fill/urgent counts, +and 100% preservation. +`/usr/bin/time -l` could not report resident memory because its +`sysctl kern.clockrate` call was denied in this environment; the demo itself +still completed successfully. + +## Categorized findings + +| Category | Severity | Confidence | Finding | Reproduction | Smallest improvement | +|---|---:|---:|---|---|---| +| Poor product fit | High | High | The isolated Fold projection demonstrates atomic durable input/outcome state and counts, but the required SQLite-to-Fold synchronization and atomic publication boundary are absent. | Inspect `src/store.rs`; no SQLite adapter or handoff exists. | Document source-of-truth and cross-store transaction boundaries; keep this scenario no-fit until a real handoff is proven. | +| Prototype correctness defect, fixed | High | High | The original validator accepted a spurious travel-conflict reason without testing travel feasibility. | Run `validator_rejects_spurious_travel_conflict`; the pre-fix reviewer reproducer returned success. | Independently check travel and error when any caregiver is fully eligible. | +| Stability | High | High | Full rescans preserved 98.2940% in the sample; the incremental path preserved 100%. | `cargo run --release --offline` | Keep published outcomes first-class and expose affected-key transactions. | +| Performance evidence | Medium | High at representative scale | Across three local runs, incremental p95 was 5.244–5.517 ms and baseline sampled p95 was 102.118–116.540 ms. | Run the release demo repeatedly. | Add full-scale, memory-bounded, multi-seed benchmarks before production claims. | +| Correctness | High | High at representative scale after the fix | Independent validation found no certification, region, availability, rest, hour, or travel violations; the regression rejects a falsely unfilled eligible visit. | Run the demo and all tests. | Add mutation/property tests across many seeds. | +| Recovery | High | High for committed state | Normal reopen was 47.200 ms and post-abort reopen was 50.553 ms with the committed change present. | Same demo; child SIGABRT is intentional. | Add a second crash point inside an uncommitted transaction. | +| Time handling | Medium | High for the narrow parser contract | Explicit-offset RFC3339 strings are parsed as instants and offset-free strings are rejected; IANA-zone ambiguity and nonexistent-wall-time policy are not tested. | Run the time unit test. | Add timezone-aware conversion at the unimplemented SQLite import boundary. | +| Developer experience | Low | High | Anonymous Fold pipeline closure types made a reusable load helper awkward. | Replace named functions in `src/store.rs` with closures and compile. | Provide a documented named-pipeline/type-alias example for reusable stores. | +| Environment | Low | High | First build requires registry access if dependencies are not cached. | Clear cache and run `cargo test --all-targets` without network. | Offer a vendored/offline lab setup. | + +## Decision audit + +- **Baseline first:** retained as a deterministic full-rescan comparator. It + met final fill and urgent coverage but failed the assignment-preservation + target and did unnecessary work on each cancellation. +- **Fold: evaluated in an isolated projection.** `KeyedStream` gives upsert/remove semantics, + atomic caregiver/visit/outcome changes, durable restart state, and + incrementally maintained counts. This is directly relevant to cancellation + events and recovery. +- **Custom scheduler: selected.** The smallest safe change is to retain valid + published assignments, remove only the canceled visit, and test the freed + caregiver against the urgency-ordered open set. It guarantees no reassignment + of unaffected visits, including those inside six hours. +- **Independent validator: selected.** It deliberately duplicates constraint + checks and reason classification instead of calling scheduler eligibility. +- **Overall decision: no fit for the stated SQLite-authoritative system.** The + isolated Fold projection is useful evidence, but it is not a safe integration + until synchronization, rebuilding, and atomic publication are demonstrated. + Do not move constraint logic into BogKit core based on this trial. + +## Alternatives rejected + +- **Fold `TopK` or aggregate as the scheduler:** no-fit because eligibility + depends on interacting travel, rest, hour, and assignment constraints; a + simple rank is not enough. +- **ESE:** no-fit; there is no semantic-text matching problem. +- **ANNy:** no-fit; exact deterministic eligibility is required, not approximate + vector similarity. +- **Global optimization:** explicitly out of scope and would make the prototype + materially larger. +- **SQLite replacement:** rejected. The existing service owns that boundary; + this trial tests a durable scheduling projection, not a database migration. +- **Full 20,000/120,000 benchmark in this pass:** rejected to keep the trial + compact. The 10% run is labeled and no linear extrapolation is claimed. + +## Unresolved uncertainty + +- Full requested scale has not been run, so p95, startup time, and the 1 GiB + limit are unproven at 20,000 caregivers and 120,000 visits. +- Peak resident memory was not measured because `/usr/bin/time -l` could not + access the required system control in this sandbox. +- The SQLite import/change-feed adapter is not implemented. +- The crash harness proves recovery after an acknowledged commit and process + abort; it does not yet inject a crash inside an uncommitted transaction. +- Explicit-offset parsing is correct only as instant arithmetic. IANA-zone + conversion and policy for ambiguous/nonexistent local wall times must be + owned by the importer. +- Only one seed and a synthetic distribution were used for the recorded + performance run. More seeds and real distribution shapes could expose + different contention and reason mixes. diff --git a/developer-simulation/runs/2026-07-31--homecare-gap-fill/README.md b/developer-simulation/runs/2026-07-31--homecare-gap-fill/README.md new file mode 100644 index 0000000..c6d74ac --- /dev/null +++ b/developer-simulation/runs/2026-07-31--homecare-gap-fill/README.md @@ -0,0 +1,96 @@ +# Caregiver scheduler cancellation trial + +This is a small, standalone Rust prototype for testing whether BogKit helps a +single-process caregiver scheduler respond to cancellation bursts without +reshuffling unaffected work. + +## Outcome + +**No fit for the SQLite-authoritative handoff as implemented.** Fold +demonstrates useful atomic state and counts inside an isolated projection, but +the required SQLite-to-Fold synchronization and atomic publication boundary +were not implemented. Fold is not a scheduling solver. The candidate index, +scheduling policy, explanations, and independent validator remain plain Rust. +ESE and ANNy do not fit this problem. + +The existing SQLite importer is represented by deterministic normalized input. +An actual SQLite adapter is deliberately outside this compact prototype. The +time parser accepts RFC3339 instants with explicit UTC offsets and rejects +offset-free strings. It does not apply IANA timezone rules or reject a +nonexistent local wall time when the caller supplies an offset. + +## What the demo does + +- Generates a seeded 14-day workload with 2,000 caregivers and 12,000 visits, + exactly 10% of the requested scale. +- Models interval availability, certifications, regions, travel time, required + rest, hour limits, urgency, and continuity preferences. +- Measures a full-rescan greedy baseline before exercising the incremental path. +- Processes a burst of 200 committed and checkpointed cancellations. Only the + canceled visit and, when possible, one newly fillable visit are changed. +- Independently checks all constraints and reason codes, including a regression + that rejects a spurious travel-conflict explanation when a caregiver is + actually eligible. +- Replays the same seed and changes and compares a deterministic digest. +- Reopens the durable state, then runs a child process that commits one change + and aborts; the parent verifies that the committed state recovers correctly. + +The last measured run produced: + +```text +BASELINE sampled_p95_ms=116.540 sampled_mean_preservation_pct=98.2940 +INCREMENTAL burst_changes=200 burst_ms=900.334 p95_ms=5.363 throughput_changes_per_s=222.1 preservation_pct=100.0000 +BASELINE filled=10000 urgent_filled=2761/2913 +INCREMENTAL filled=10000 urgent_filled=2761/2913 +RESTART recovery_ms=47.200 +CRASH_RESTART recovery_ms=50.553 +REPLAY digest=3a9719e0c8654377 +``` + +Across the author run and two reviewer reruns, baseline sampled p95 was +102.118–116.540 ms and incremental p95 was 5.244–5.517 ms. These are +representative-scale measurements on this machine, not evidence that the full +20,000/120,000 workload or the 1 GiB limit passes. + +## Reproduce + +From this directory: + +```console +cargo test --all-targets +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo run --release +``` + +After dependencies have been downloaded once, append `--offline` before the +final `--` (if any) to reproduce without network access: + +```console +cargo test --all-targets --offline +cargo clippy --all-targets --offline -- -D warnings +cargo run --release --offline +``` + +The demo recreates `target/caregiver-scheduler-demo-state` on each run. The +child abort is intentional and is treated as success only if the parent can +recover and validate its committed change. + +## Archive layout + +This crate is a member of the nested `developer-simulation` workspace and uses: + +```toml +fold = { path = "../../../fold" } +``` + +No ESE or ANNy dependency is included because neither was selected. + +## Files + +- `src/model.rs`: normalized records, seeded data, explicit-offset time import. +- `src/scheduler.rs`: baseline and incremental scheduling paths. +- `src/store.rs`: Fold-backed atomic records and incremental count views. +- `src/validator.rs`: independent constraint and explanation checker. +- `src/main.rs`: measurements, replay, recovery, and crash harness. +- `EVIDENCE.md`: discovery log, exact command results, findings, and decision. diff --git a/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/lib.rs b/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/lib.rs new file mode 100644 index 0000000..c63b8ce --- /dev/null +++ b/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/lib.rs @@ -0,0 +1,126 @@ +pub mod model; +pub mod scheduler; +pub mod store; +pub mod validator; + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::model::{ + Caregiver, EntityKey, Interval, Outcome, Record, Scale, State, UnfilledReason, Visit, + generate, parse_import_minute, + }; + use crate::scheduler::{CandidateIndex, IncrementalScheduler, Mode, build_schedule}; + use crate::store::{load_state, open_store, persist_initial}; + use crate::validator::validate; + + #[test] + fn explicit_offsets_handle_dst_gap_and_fold() { + let before_gap = parse_import_minute("2026-03-08T01:30:00-05:00").unwrap(); + let after_gap = parse_import_minute("2026-03-08T03:30:00-04:00").unwrap(); + assert_eq!(after_gap - before_gap, 60); + + let first_fold = parse_import_minute("2026-11-01T01:30:00-04:00").unwrap(); + let second_fold = parse_import_minute("2026-11-01T01:30:00-05:00").unwrap(); + assert_eq!(second_fold - first_fold, 60); + assert!(parse_import_minute("2026-03-08T02:30:00").is_err()); + } + + #[test] + fn scheduler_is_deterministic_and_validator_accepts_it() { + let state = generate(7, Scale::Tiny); + let index = CandidateIndex::new(&state); + let first = build_schedule(&state, &index, Mode::ContinuityAware); + let second = build_schedule(&state, &index, Mode::ContinuityAware); + assert_eq!(first, second); + validate(&state, &first, None).unwrap(); + } + + #[test] + fn validator_rejects_spurious_travel_conflict() { + let caregiver = Caregiver { + id: 7, + certification_mask: 1, + region_mask: 1, + availability: vec![Interval { start: 0, end: 600 }], + required_rest: Vec::new(), + max_minutes: 600, + }; + let visit = Visit { + id: 11, + client_id: 1, + start: 60, + end: 120, + region: 0, + required_certification: 0, + urgency: 5, + preferred_caregiver: None, + canceled: false, + }; + let state = State { + caregivers: [(caregiver.id, caregiver)].into(), + visits: [(visit.id, visit)].into(), + }; + let outcomes = [(11, Outcome::Unfilled(UnfilledReason::TravelConflict))].into(); + + let error = validate(&state, &outcomes, None).unwrap_err(); + assert!(error.contains("caregiver 7 is independently eligible")); + } + + #[test] + fn cancellation_preserves_unaffected_assignments() { + let mut state = generate(7, Scale::Tiny); + let index = CandidateIndex::new(&state); + let schedule = build_schedule(&state, &index, Mode::ContinuityAware); + let before: BTreeMap<_, _> = schedule + .iter() + .filter_map(|(visit, outcome)| match outcome { + Outcome::Assigned(caregiver) => Some((*visit, *caregiver)), + Outcome::Unfilled(_) => None, + }) + .collect(); + let cancel_id = *before.keys().next().unwrap(); + let mut incremental = IncrementalScheduler::new(&state, schedule); + incremental.cancel(&mut state, cancel_id).unwrap(); + validate(&state, incremental.outcomes(), Some((&before, cancel_id))).unwrap(); + } + + #[test] + fn fold_round_trip_recovers_records_and_metrics() { + let state = generate(9, Scale::Tiny); + let index = CandidateIndex::new(&state); + let schedule = build_schedule(&state, &index, Mode::ContinuityAware); + let path = std::env::current_dir() + .unwrap() + .join("target/fold-round-trip-test"); + let _ = std::fs::remove_dir_all(&path); + { + let mut store = open_store(&path); + persist_initial(&mut store, &state, &schedule); + store.checkpoint(); + } + let store = open_store(&path); + let (loaded, loaded_schedule, metrics) = load_state(&store); + assert_eq!(state, loaded); + assert_eq!(schedule, loaded_schedule); + assert_eq!(metrics.caregivers, state.caregivers.len() as i64); + assert_eq!( + metrics.assignments, + schedule + .values() + .filter(|o| matches!(o, Outcome::Assigned(_))) + .count() as i64 + ); + drop(store); + let _ = std::fs::remove_dir_all(path); + + let _type_check = ( + EntityKey::Visit(0), + Record::Outcome { + visit_id: 0, + outcome: Outcome::Unfilled(crate::model::UnfilledReason::NoCertification), + }, + ); + } +} diff --git a/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/main.rs b/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/main.rs new file mode 100644 index 0000000..6ff439e --- /dev/null +++ b/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/main.rs @@ -0,0 +1,349 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::process::Command; +use std::time::{Duration, Instant}; + +use homecare_gap_fill::model::{Outcome, Scale, State, UnfilledReason, VisitId, generate}; +use homecare_gap_fill::scheduler::{ + CandidateIndex, IncrementalScheduler, Mode, build_schedule, counts, digest, +}; +use homecare_gap_fill::store::{load_state, open_store, persist_cancellation, persist_initial}; +use homecare_gap_fill::validator::{PublishedAssignments, validate}; + +const SEED: u64 = 0x5eed_cafe; +const BURST_CHANGES: usize = 200; +const BASELINE_LATENCY_SAMPLES: usize = 12; + +fn main() -> Result<(), String> { + let args: Vec = std::env::args().collect(); + if args.get(1).map(String::as_str) == Some("--crash-after-commit") { + let path = args.get(2).ok_or("missing crash database path")?; + let visit_id = args + .get(3) + .ok_or("missing crash visit id")? + .parse::() + .map_err(|error| error.to_string())?; + crash_after_commit(Path::new(path), visit_id); + } + run_demo() +} + +fn run_demo() -> Result<(), String> { + let generated_at = Instant::now(); + let seed_state = generate(SEED, Scale::Representative); + let generation_time = generated_at.elapsed(); + let index = CandidateIndex::new(&seed_state); + + let baseline_started = Instant::now(); + let baseline_initial = build_schedule(&seed_state, &index, Mode::Baseline); + let baseline_initial_time = baseline_started.elapsed(); + validate(&seed_state, &baseline_initial, None)?; + + let incremental_started = Instant::now(); + let incremental_initial = build_schedule(&seed_state, &index, Mode::ContinuityAware); + let incremental_initial_time = incremental_started.elapsed(); + validate(&seed_state, &incremental_initial, None)?; + + let cancellation_ids = choose_cancellations(&seed_state, &incremental_initial); + if cancellation_ids.len() != BURST_CHANGES { + return Err(format!( + "needed {BURST_CHANGES} assigned visits for burst, found {}", + cancellation_ids.len() + )); + } + + let (baseline_p95, baseline_preservation) = baseline_latency_sample( + &seed_state, + &index, + &baseline_initial, + &cancellation_ids[..BASELINE_LATENCY_SAMPLES], + )?; + + let mut baseline_final_state = seed_state.clone(); + for visit_id in &cancellation_ids { + baseline_final_state + .visits + .get_mut(visit_id) + .unwrap() + .canceled = true; + } + let baseline_final_started = Instant::now(); + let baseline_final = build_schedule(&baseline_final_state, &index, Mode::Baseline); + let baseline_final_time = baseline_final_started.elapsed(); + validate(&baseline_final_state, &baseline_final, None)?; + + let demo_path = std::env::current_dir() + .map_err(|error| error.to_string())? + .join("target/caregiver-scheduler-demo-state"); + if demo_path.exists() { + std::fs::remove_dir_all(&demo_path).map_err(|error| error.to_string())?; + } + let mut store = open_store(&demo_path); + persist_initial(&mut store, &seed_state, &incremental_initial); + store.checkpoint(); + + let initial_published = published(&incremental_initial); + let mut incremental_state = seed_state.clone(); + let mut incremental = IncrementalScheduler::new(&incremental_state, incremental_initial); + let burst_started = Instant::now(); + let mut incremental_latencies = Vec::with_capacity(cancellation_ids.len()); + for visit_id in &cancellation_ids { + let started = Instant::now(); + let delta = incremental.cancel(&mut incremental_state, *visit_id)?; + persist_cancellation( + &mut store, + &incremental_state, + incremental.outcomes(), + delta, + ); + store.checkpoint(); + incremental_latencies.push(started.elapsed()); + } + let burst_time = burst_started.elapsed(); + let incremental_p95 = percentile_95(&incremental_latencies); + let final_incremental_digest = digest(&incremental_state, incremental.outcomes()); + validate( + &incremental_state, + incremental.outcomes(), + Some((&initial_published, cancellation_ids[0])), + )?; + let preservation = preservation_ratio( + &initial_published, + incremental.outcomes(), + &cancellation_ids.iter().copied().collect(), + ); + + let baseline_counts = counts(&baseline_final, &baseline_final_state); + let incremental_counts = counts(incremental.outcomes(), &incremental_state); + if incremental_counts.0 < baseline_counts.0 || incremental_counts.1 < baseline_counts.1 { + return Err(format!( + "incremental coverage regressed: baseline {baseline_counts:?}, incremental {incremental_counts:?}" + )); + } + if preservation < 0.995 { + return Err(format!("preservation {preservation:.6} missed 99.5%")); + } + + let replay_started = Instant::now(); + let mut replay_state = generate(SEED, Scale::Representative); + let replay_index = CandidateIndex::new(&replay_state); + let replay_initial = build_schedule(&replay_state, &replay_index, Mode::ContinuityAware); + let mut replay = IncrementalScheduler::new(&replay_state, replay_initial); + for visit_id in &cancellation_ids { + replay.cancel(&mut replay_state, *visit_id)?; + } + let replay_digest = digest(&replay_state, replay.outcomes()); + let replay_time = replay_started.elapsed(); + if replay_digest != final_incremental_digest { + return Err("deterministic replay digest mismatch".to_string()); + } + + drop(store); + let recovery_started = Instant::now(); + let recovered_store = open_store(&demo_path); + let (recovered_state, recovered_outcomes, recovered_metrics) = load_state(&recovered_store); + let recovery_time = recovery_started.elapsed(); + validate(&recovered_state, &recovered_outcomes, None)?; + if digest(&recovered_state, &recovered_outcomes) != final_incremental_digest { + return Err("restart recovery digest mismatch".to_string()); + } + if recovery_time >= Duration::from_secs(30) { + return Err("restart recovery exceeded 30 seconds".to_string()); + } + drop(recovered_store); + + let crash_visit = recovered_outcomes + .iter() + .find_map(|(id, outcome)| matches!(outcome, Outcome::Assigned(_)).then_some(*id)) + .ok_or("no assignment available for crash harness")?; + let crash = Command::new(std::env::current_exe().map_err(|error| error.to_string())?) + .arg("--crash-after-commit") + .arg(&demo_path) + .arg(crash_visit.to_string()) + .output() + .map_err(|error| format!("failed to run crash child: {error}"))?; + if crash.status.success() { + return Err("crash harness child unexpectedly exited cleanly".to_string()); + } + let crash_recovery_started = Instant::now(); + let crashed_store = open_store(&demo_path); + let (crashed_state, crashed_outcomes, crashed_metrics) = load_state(&crashed_store); + let crash_recovery_time = crash_recovery_started.elapsed(); + if !crashed_state.visits[&crash_visit].canceled { + return Err("committed cancellation was lost after crash".to_string()); + } + validate(&crashed_state, &crashed_outcomes, None)?; + + let reason_counts = reason_counts(incremental.outcomes()); + let change_rate = cancellation_ids.len() as f64 / burst_time.as_secs_f64(); + println!( + "DATASET label=10%-representative caregivers={} visits={} horizon_days=14 generated_ms={:.3}", + seed_state.caregivers.len(), + seed_state.visits.len(), + generation_time.as_secs_f64() * 1_000.0 + ); + println!( + "BOGKIT component=fold role=atomic-keyed-persistence-and-materialized-counts ese=no-fit anny=no-fit" + ); + println!( + "BASELINE initial_ms={:.3} sampled_changes={} sampled_p95_ms={:.3} sampled_mean_preservation_pct={:.4} final_rescan_ms={:.3} filled={} urgent_filled={}/{}", + baseline_initial_time.as_secs_f64() * 1_000.0, + BASELINE_LATENCY_SAMPLES, + baseline_p95.as_secs_f64() * 1_000.0, + baseline_preservation * 100.0, + baseline_final_time.as_secs_f64() * 1_000.0, + baseline_counts.0, + baseline_counts.1, + baseline_counts.2, + ); + println!( + "INCREMENTAL initial_ms={:.3} burst_changes={} burst_ms={:.3} p95_ms={:.3} throughput_changes_per_s={:.1} preservation_pct={:.4} filled={} urgent_filled={}/{}", + incremental_initial_time.as_secs_f64() * 1_000.0, + cancellation_ids.len(), + burst_time.as_secs_f64() * 1_000.0, + incremental_p95.as_secs_f64() * 1_000.0, + change_rate, + preservation * 100.0, + incremental_counts.0, + incremental_counts.1, + incremental_counts.2, + ); + println!( + "VALIDATOR status=ok constraint_violations=0 active_visits={} outcomes={} unfilled_reason_codes={:?}", + incremental_state + .visits + .values() + .filter(|visit| !visit.canceled) + .count(), + incremental.outcomes().len(), + reason_counts, + ); + println!( + "REPLAY status=deterministic digest={final_incremental_digest:016x} replay_ms={:.3}", + replay_time.as_secs_f64() * 1_000.0 + ); + println!( + "RESTART status=ok recovery_ms={:.3} caregivers={} active_visits={} assignments={} unfilled={}", + recovery_time.as_secs_f64() * 1_000.0, + recovered_metrics.caregivers, + recovered_metrics.active_visits, + recovered_metrics.assignments, + recovered_metrics.unfilled, + ); + println!( + "CRASH_RESTART status=ok child_status={} recovery_ms={:.3} committed_visit={} canceled_visits={}", + crash.status, + crash_recovery_time.as_secs_f64() * 1_000.0, + crash_visit, + crashed_metrics.canceled_visits, + ); + println!("STATE path={}", demo_path.display()); + Ok(()) +} + +fn crash_after_commit(path: &Path, visit_id: VisitId) -> ! { + let mut store = open_store(path); + let (mut state, outcomes, _) = load_state(&store); + let mut scheduler = IncrementalScheduler::new(&state, outcomes); + let delta = scheduler.cancel(&mut state, visit_id).unwrap(); + persist_cancellation(&mut store, &state, scheduler.outcomes(), delta); + store.checkpoint(); + eprintln!("crash harness: committed visit {visit_id}, aborting now"); + std::process::abort(); +} + +fn choose_cancellations(state: &State, outcomes: &BTreeMap) -> Vec { + outcomes + .iter() + .filter_map(|(id, outcome)| { + let visit = &state.visits[id]; + (matches!(outcome, Outcome::Assigned(_)) && visit.start >= state.visits[&0].start + 360) + .then_some(*id) + }) + .take(BURST_CHANGES) + .collect() +} + +fn baseline_latency_sample( + seed_state: &State, + index: &CandidateIndex, + initial: &BTreeMap, + cancellations: &[VisitId], +) -> Result<(Duration, f64), String> { + let mut state = seed_state.clone(); + let mut schedule = initial.clone(); + let mut latencies = Vec::new(); + let mut preservation_sum = 0.0; + for visit_id in cancellations { + let before = published(&schedule); + state.visits.get_mut(visit_id).unwrap().canceled = true; + let started = Instant::now(); + schedule = build_schedule(&state, index, Mode::Baseline); + latencies.push(started.elapsed()); + validate(&state, &schedule, None)?; + preservation_sum += preservation_ratio(&before, &schedule, &BTreeSet::from([*visit_id])); + } + Ok(( + percentile_95(&latencies), + preservation_sum / cancellations.len() as f64, + )) +} + +fn published(outcomes: &BTreeMap) -> PublishedAssignments { + outcomes + .iter() + .filter_map(|(visit, outcome)| match outcome { + Outcome::Assigned(caregiver) => Some((*visit, *caregiver)), + Outcome::Unfilled(_) => None, + }) + .collect() +} + +fn preservation_ratio( + before: &PublishedAssignments, + after: &BTreeMap, + affected: &BTreeSet, +) -> f64 { + let mut eligible = 0; + let mut preserved = 0; + for (visit_id, caregiver_id) in before { + if affected.contains(visit_id) { + continue; + } + eligible += 1; + if after.get(visit_id) == Some(&Outcome::Assigned(*caregiver_id)) { + preserved += 1; + } + } + if eligible == 0 { + 1.0 + } else { + preserved as f64 / eligible as f64 + } +} + +fn percentile_95(samples: &[Duration]) -> Duration { + let mut sorted = samples.to_vec(); + sorted.sort_unstable(); + sorted[((sorted.len() * 95).div_ceil(100)).saturating_sub(1)] +} + +fn reason_counts(outcomes: &BTreeMap) -> BTreeMap<&'static str, usize> { + let mut counts = BTreeMap::new(); + for outcome in outcomes.values() { + if let Outcome::Unfilled(reason) = outcome { + *counts.entry(reason.code()).or_default() += 1; + } + } + for reason in [ + UnfilledReason::NoCertification, + UnfilledReason::NoRegionCoverage, + UnfilledReason::OutsideAvailability, + UnfilledReason::RequiredRest, + UnfilledReason::HourLimit, + UnfilledReason::TravelConflict, + ] { + counts.entry(reason.code()).or_insert(0); + } + counts +} diff --git a/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/model.rs b/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/model.rs new file mode 100644 index 0000000..695898c --- /dev/null +++ b/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/model.rs @@ -0,0 +1,226 @@ +use std::collections::BTreeMap; + +use chrono::DateTime; +use serde::{Deserialize, Serialize}; + +pub type Minute = i64; +pub type CaregiverId = u32; +pub type VisitId = u64; + +pub const REGIONS: u8 = 8; +pub const CERTIFICATIONS: u8 = 8; +pub const BASE_MINUTE: Minute = 29_541_900; // 2026-03-02T00:00:00-05:00 + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scale { + Tiny, + Representative, +} + +impl Scale { + pub fn counts(self) -> (u32, u64) { + match self { + Self::Tiny => (80, 480), + Self::Representative => (2_000, 12_000), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Interval { + pub start: Minute, + pub end: Minute, +} + +impl Interval { + pub fn contains(self, start: Minute, end: Minute) -> bool { + self.start <= start && end <= self.end + } + + pub fn overlaps(self, start: Minute, end: Minute) -> bool { + self.start < end && start < self.end + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Caregiver { + pub id: CaregiverId, + pub certification_mask: u16, + pub region_mask: u16, + pub availability: Vec, + pub required_rest: Vec, + pub max_minutes: Minute, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Visit { + pub id: VisitId, + pub client_id: u32, + pub start: Minute, + pub end: Minute, + pub region: u8, + pub required_certification: u8, + pub urgency: u8, + pub preferred_caregiver: Option, + pub canceled: bool, +} + +impl Visit { + pub fn duration(&self) -> Minute { + self.end - self.start + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum UnfilledReason { + NoCertification, + NoRegionCoverage, + OutsideAvailability, + RequiredRest, + HourLimit, + TravelConflict, +} + +impl UnfilledReason { + pub fn code(self) -> &'static str { + match self { + Self::NoCertification => "NO_CERTIFICATION", + Self::NoRegionCoverage => "NO_REGION_COVERAGE", + Self::OutsideAvailability => "OUTSIDE_AVAILABILITY", + Self::RequiredRest => "REQUIRED_REST", + Self::HourLimit => "HOUR_LIMIT", + Self::TravelConflict => "TRAVEL_CONFLICT", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Outcome { + Assigned(CaregiverId), + Unfilled(UnfilledReason), +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct State { + pub caregivers: BTreeMap, + pub visits: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum EntityKey { + Caregiver(CaregiverId), + Visit(VisitId), + Outcome(VisitId), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Record { + Caregiver(Caregiver), + Visit(Visit), + Outcome { visit_id: VisitId, outcome: Outcome }, +} + +#[derive(Clone, Copy)] +struct Rng(u64); + +impl Rng { + fn new(seed: u64) -> Self { + Self(seed) + } + + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn range(&mut self, upper: u64) -> u64 { + self.next() % upper + } +} + +pub fn parse_import_minute(value: &str) -> Result { + DateTime::parse_from_rfc3339(value) + .map(|value| value.timestamp() / 60) + .map_err(|error| format!("timestamp must be RFC3339 with an explicit offset: {error}")) +} + +pub fn generate(seed: u64, scale: Scale) -> State { + let (caregiver_count, visit_count) = scale.counts(); + let mut rng = Rng::new(seed); + let mut caregivers = BTreeMap::new(); + for id in 0..caregiver_count { + let home_region = (id % u32::from(REGIONS)) as u8; + let second_region = (home_region + 1 + (rng.range(2) as u8)) % REGIONS; + let first_cert = rng.range(u64::from(CERTIFICATIONS)) as u8; + let second_cert = (first_cert + 1 + rng.range(3) as u8) % CERTIFICATIONS; + let mut availability = Vec::with_capacity(14); + let mut required_rest = Vec::with_capacity(14); + for day in 0..14 { + let day_start = BASE_MINUTE + day * 1_440; + availability.push(Interval { + start: day_start + 6 * 60, + end: day_start + 23 * 60, + }); + required_rest.push(Interval { + start: day_start + 22 * 60, + end: day_start + 23 * 60, + }); + } + caregivers.insert( + id, + Caregiver { + id, + certification_mask: (1 << first_cert) | (1 << second_cert), + region_mask: (1 << home_region) | (1 << second_region), + availability, + required_rest, + max_minutes: 4 * 60, + }, + ); + } + + let mut visits = BTreeMap::new(); + for id in 0..visit_count { + let day = (id % 14) as i64; + let slot = rng.range(60) as i64; + let night_case = id % 97 == 0; + let start = if night_case { + BASE_MINUTE + day * 1_440 + 22 * 60 + } else { + BASE_MINUTE + day * 1_440 + 7 * 60 + slot * 15 + }; + let required_certification = if id % 211 == 0 { + 15 + } else { + rng.range(u64::from(CERTIFICATIONS)) as u8 + }; + let preferred = if id % 3 == 0 { + Some(rng.range(u64::from(caregiver_count)) as u32) + } else { + None + }; + visits.insert( + id, + Visit { + id, + client_id: (id % (visit_count / 5).max(1)) as u32, + start, + end: start + 45, + region: rng.range(u64::from(REGIONS)) as u8, + required_certification, + urgency: (rng.range(4) + 1) as u8, + preferred_caregiver: preferred, + canceled: false, + }, + ); + } + + State { caregivers, visits } +} + +pub fn travel_minutes(from_region: u8, to_region: u8) -> Minute { + let distance = (i64::from(from_region) - i64::from(to_region)).abs(); + 10 + distance * 5 +} diff --git a/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/scheduler.rs b/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/scheduler.rs new file mode 100644 index 0000000..04bd99e --- /dev/null +++ b/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/scheduler.rs @@ -0,0 +1,345 @@ +use std::cmp::Reverse; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::model::{ + CERTIFICATIONS, Caregiver, CaregiverId, Minute, Outcome, REGIONS, State, UnfilledReason, Visit, + VisitId, travel_minutes, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Baseline, + ContinuityAware, +} + +#[derive(Debug)] +pub struct CandidateIndex { + by_region_certification: Vec>>, +} + +impl CandidateIndex { + pub fn new(state: &State) -> Self { + let mut by_region_certification = + vec![vec![Vec::new(); usize::from(CERTIFICATIONS)]; usize::from(REGIONS)]; + for caregiver in state.caregivers.values() { + for region in 0..REGIONS { + for certification in 0..CERTIFICATIONS { + if caregiver.region_mask & (1 << region) != 0 + && caregiver.certification_mask & (1 << certification) != 0 + { + by_region_certification[usize::from(region)][usize::from(certification)] + .push(caregiver.id); + } + } + } + } + Self { + by_region_certification, + } + } + + pub fn candidates(&self, visit: &Visit) -> &[CaregiverId] { + if visit.region >= REGIONS || visit.required_certification >= CERTIFICATIONS { + return &[]; + } + &self.by_region_certification[usize::from(visit.region)] + [usize::from(visit.required_certification)] + } +} + +type PriorityKey = (Reverse, Minute, VisitId); + +fn priority(visit: &Visit) -> PriorityKey { + (Reverse(visit.urgency), visit.start, visit.id) +} + +#[derive(Default)] +struct WorkingSchedule { + outcomes: BTreeMap, + by_caregiver: BTreeMap>, + minutes: BTreeMap, +} + +impl WorkingSchedule { + fn assigned_visits<'a>( + &'a self, + state: &'a State, + caregiver_id: CaregiverId, + ) -> impl Iterator { + self.by_caregiver + .get(&caregiver_id) + .into_iter() + .flatten() + .filter_map(|id| state.visits.get(id)) + } + + fn assign(&mut self, visit: &Visit, caregiver_id: CaregiverId) { + self.outcomes + .insert(visit.id, Outcome::Assigned(caregiver_id)); + self.by_caregiver + .entry(caregiver_id) + .or_default() + .insert(visit.id); + *self.minutes.entry(caregiver_id).or_default() += visit.duration(); + } +} + +pub fn build_schedule( + state: &State, + index: &CandidateIndex, + mode: Mode, +) -> BTreeMap { + let mut work = WorkingSchedule::default(); + let mut order: Vec<_> = state + .visits + .values() + .filter(|visit| !visit.canceled) + .collect(); + order.sort_by_key(|visit| priority(visit)); + + for visit in order { + let mut eligible: Vec<_> = index + .candidates(visit) + .iter() + .copied() + .filter(|caregiver_id| can_assign(state, &work, *caregiver_id, visit)) + .collect(); + match mode { + Mode::Baseline => eligible.sort_unstable(), + Mode::ContinuityAware => eligible.sort_by_key(|caregiver_id| { + ( + visit.preferred_caregiver != Some(*caregiver_id), + work.minutes.get(caregiver_id).copied().unwrap_or(0), + *caregiver_id, + ) + }), + } + if let Some(caregiver_id) = eligible.first().copied() { + work.assign(visit, caregiver_id); + } else { + work.outcomes + .insert(visit.id, Outcome::Unfilled(UnfilledReason::TravelConflict)); + } + } + + let unfilled: Vec<_> = work + .outcomes + .iter() + .filter_map(|(id, outcome)| matches!(outcome, Outcome::Unfilled(_)).then_some(*id)) + .collect(); + for id in unfilled { + let visit = &state.visits[&id]; + let reason = explain_unfilled(state, &work, visit); + work.outcomes.insert(id, Outcome::Unfilled(reason)); + } + work.outcomes +} + +fn covers(caregiver: &Caregiver, visit: &Visit) -> bool { + caregiver + .availability + .iter() + .any(|window| window.contains(visit.start, visit.end)) +} + +fn rests(caregiver: &Caregiver, visit: &Visit) -> bool { + caregiver + .required_rest + .iter() + .any(|rest| rest.overlaps(visit.start, visit.end)) +} + +fn has_travel_room(existing: &Visit, candidate: &Visit) -> bool { + if existing.end <= candidate.start { + existing.end + travel_minutes(existing.region, candidate.region) <= candidate.start + } else if candidate.end <= existing.start { + candidate.end + travel_minutes(candidate.region, existing.region) <= existing.start + } else { + false + } +} + +fn can_assign( + state: &State, + work: &WorkingSchedule, + caregiver_id: CaregiverId, + visit: &Visit, +) -> bool { + let Some(caregiver) = state.caregivers.get(&caregiver_id) else { + return false; + }; + caregiver.certification_mask & (1 << visit.required_certification) != 0 + && caregiver.region_mask & (1 << visit.region) != 0 + && covers(caregiver, visit) + && !rests(caregiver, visit) + && work.minutes.get(&caregiver_id).copied().unwrap_or(0) + visit.duration() + <= caregiver.max_minutes + && work + .assigned_visits(state, caregiver_id) + .all(|existing| has_travel_room(existing, visit)) +} + +fn explain_unfilled(state: &State, work: &WorkingSchedule, visit: &Visit) -> UnfilledReason { + let mut candidates: Vec<_> = state.caregivers.values().collect(); + candidates.retain(|caregiver| { + caregiver.certification_mask & (1 << visit.required_certification) != 0 + }); + if candidates.is_empty() { + return UnfilledReason::NoCertification; + } + candidates.retain(|caregiver| caregiver.region_mask & (1 << visit.region) != 0); + if candidates.is_empty() { + return UnfilledReason::NoRegionCoverage; + } + candidates.retain(|caregiver| covers(caregiver, visit)); + if candidates.is_empty() { + return UnfilledReason::OutsideAvailability; + } + candidates.retain(|caregiver| !rests(caregiver, visit)); + if candidates.is_empty() { + return UnfilledReason::RequiredRest; + } + candidates.retain(|caregiver| { + work.minutes.get(&caregiver.id).copied().unwrap_or(0) + visit.duration() + <= caregiver.max_minutes + }); + if candidates.is_empty() { + return UnfilledReason::HourLimit; + } + UnfilledReason::TravelConflict +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CancellationDelta { + pub canceled_visit: VisitId, + pub replacement_visit: Option, +} + +pub struct IncrementalScheduler { + work: WorkingSchedule, + open: BTreeSet, +} + +impl IncrementalScheduler { + pub fn new(state: &State, outcomes: BTreeMap) -> Self { + let mut work = WorkingSchedule { + outcomes, + ..WorkingSchedule::default() + }; + let mut open = BTreeSet::new(); + for (id, outcome) in &work.outcomes { + let visit = &state.visits[id]; + match outcome { + Outcome::Assigned(caregiver_id) => { + work.by_caregiver + .entry(*caregiver_id) + .or_default() + .insert(*id); + *work.minutes.entry(*caregiver_id).or_default() += visit.duration(); + } + Outcome::Unfilled(_) => { + open.insert(priority(visit)); + } + } + } + Self { work, open } + } + + pub fn outcomes(&self) -> &BTreeMap { + &self.work.outcomes + } + + pub fn cancel( + &mut self, + state: &mut State, + visit_id: VisitId, + ) -> Result { + let visit = state + .visits + .get_mut(&visit_id) + .ok_or_else(|| format!("unknown visit {visit_id}"))?; + if visit.canceled { + return Err(format!("visit {visit_id} was already canceled")); + } + visit.canceled = true; + let canceled_visit = visit.clone(); + let old = self + .work + .outcomes + .remove(&visit_id) + .ok_or_else(|| format!("visit {visit_id} had no published outcome"))?; + self.open.remove(&priority(&canceled_visit)); + + let freed = match old { + Outcome::Assigned(caregiver_id) => { + self.work + .by_caregiver + .entry(caregiver_id) + .or_default() + .remove(&visit_id); + *self.work.minutes.entry(caregiver_id).or_default() -= canceled_visit.duration(); + Some(caregiver_id) + } + Outcome::Unfilled(_) => None, + }; + + let mut replacement = None; + if let Some(caregiver_id) = freed { + for key in &self.open { + let open_visit = &state.visits[&key.2]; + if can_assign(state, &self.work, caregiver_id, open_visit) { + replacement = Some(open_visit.id); + break; + } + } + if let Some(replacement_id) = replacement { + let open_visit = &state.visits[&replacement_id]; + self.open.remove(&priority(open_visit)); + self.work.assign(open_visit, caregiver_id); + } + } + + Ok(CancellationDelta { + canceled_visit: visit_id, + replacement_visit: replacement, + }) + } +} + +pub fn counts(outcomes: &BTreeMap, state: &State) -> (usize, usize, usize) { + let mut filled = 0; + let mut urgent_total = 0; + let mut urgent_filled = 0; + for (id, outcome) in outcomes { + let visit = &state.visits[id]; + if visit.urgency >= 4 { + urgent_total += 1; + } + if matches!(outcome, Outcome::Assigned(_)) { + filled += 1; + if visit.urgency >= 4 { + urgent_filled += 1; + } + } + } + (filled, urgent_filled, urgent_total) +} + +pub fn digest(state: &State, outcomes: &BTreeMap) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for visit in state.visits.values() { + hash ^= visit.id; + hash = hash.wrapping_mul(0x100_0000_01b3); + hash ^= u64::from(visit.canceled); + hash = hash.wrapping_mul(0x100_0000_01b3); + if let Some(outcome) = outcomes.get(&visit.id) { + let word = match outcome { + Outcome::Assigned(id) => u64::from(*id) << 1, + Outcome::Unfilled(reason) => 1 | ((*reason as u64) << 8), + }; + hash ^= word; + hash = hash.wrapping_mul(0x100_0000_01b3); + } + } + hash +} diff --git a/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/store.rs b/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/store.rs new file mode 100644 index 0000000..f5cdfcc --- /dev/null +++ b/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/store.rs @@ -0,0 +1,155 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use fold::pipeline::{Aggregate, FilterMap, KeyBy, Keyed, terminal}; +use fold::stream::KeyedStream; +use serde::{Deserialize, Serialize}; + +use crate::model::{EntityKey, Outcome, Record, State, VisitId}; +use crate::scheduler::CancellationDelta; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Metric { + Caregivers, + ActiveVisits, + CanceledVisits, + Assignments, + Unfilled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Metrics { + pub caregivers: i64, + pub active_visits: i64, + pub canceled_visits: i64, + pub assignments: i64, + pub unfilled: i64, +} + +type MetricAggregate = + Aggregate>; +type MetricKeyBy = KeyBy Metric, MetricAggregate, Metric, Metric>; +type MetricPipeline = FilterMap< + fn(&Keyed) -> Option, + MetricKeyBy, + Keyed, + Metric, +>; +type Pipeline = (terminal::Table, MetricPipeline); +pub type Store = KeyedStream; + +fn record_metric(row: &Keyed) -> Option { + match &row.val { + Record::Caregiver(_) => Some(Metric::Caregivers), + Record::Visit(visit) if visit.canceled => Some(Metric::CanceledVisits), + Record::Visit(_) => Some(Metric::ActiveVisits), + Record::Outcome { + outcome: Outcome::Assigned(_), + .. + } => Some(Metric::Assignments), + Record::Outcome { + outcome: Outcome::Unfilled(_), + .. + } => Some(Metric::Unfilled), + } +} + +fn same_metric(metric: &Metric) -> Metric { + *metric +} + +fn count_metric(count: &mut i64, _metric: &Metric, delta: isize) { + *count += delta as i64; +} + +pub fn open_store(path: impl AsRef) -> Store { + let records = terminal::Table::::new("records"); + let metrics = FilterMap::new( + record_metric as fn(&Keyed) -> Option, + KeyBy::new( + same_metric as fn(&Metric) -> Metric, + Aggregate::new( + "metric_counts", + count_metric as fn(&mut i64, &Metric, isize), + terminal::Table::::new("metrics"), + ), + ), + ); + KeyedStream::new(path, (records, metrics)) +} + +pub fn persist_initial(store: &mut Store, state: &State, outcomes: &BTreeMap) { + store.wtx(|tx| { + for caregiver in state.caregivers.values() { + tx.upsert( + &EntityKey::Caregiver(caregiver.id), + &Record::Caregiver(caregiver.clone()), + ); + } + for visit in state.visits.values() { + tx.upsert(&EntityKey::Visit(visit.id), &Record::Visit(visit.clone())); + } + for (visit_id, outcome) in outcomes { + tx.upsert( + &EntityKey::Outcome(*visit_id), + &Record::Outcome { + visit_id: *visit_id, + outcome: *outcome, + }, + ); + } + }); +} + +pub fn persist_cancellation( + store: &mut Store, + state: &State, + outcomes: &BTreeMap, + delta: CancellationDelta, +) { + let canceled = state.visits[&delta.canceled_visit].clone(); + store.wtx(|tx| { + tx.upsert( + &EntityKey::Visit(delta.canceled_visit), + &Record::Visit(canceled), + ); + tx.remove(&EntityKey::Outcome(delta.canceled_visit)); + if let Some(visit_id) = delta.replacement_visit { + tx.upsert( + &EntityKey::Outcome(visit_id), + &Record::Outcome { + visit_id, + outcome: outcomes[&visit_id], + }, + ); + } + }); +} + +pub fn load_state(store: &Store) -> (State, BTreeMap, Metrics) { + store.rtx(|(records, metrics)| { + let mut state = State::default(); + let mut outcomes = BTreeMap::new(); + for (_, record) in records.iter() { + match record { + Record::Caregiver(caregiver) => { + state.caregivers.insert(caregiver.id, caregiver); + } + Record::Visit(visit) => { + state.visits.insert(visit.id, visit); + } + Record::Outcome { visit_id, outcome } => { + outcomes.insert(visit_id, outcome); + } + } + } + let metrics = Metrics { + caregivers: metrics.get(&Metric::Caregivers).unwrap_or(0), + active_visits: metrics.get(&Metric::ActiveVisits).unwrap_or(0), + canceled_visits: metrics.get(&Metric::CanceledVisits).unwrap_or(0), + assignments: metrics.get(&Metric::Assignments).unwrap_or(0), + unfilled: metrics.get(&Metric::Unfilled).unwrap_or(0), + }; + (state, outcomes, metrics) + }) +} diff --git a/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/validator.rs b/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/validator.rs new file mode 100644 index 0000000..29cb719 --- /dev/null +++ b/developer-simulation/runs/2026-07-31--homecare-gap-fill/src/validator.rs @@ -0,0 +1,193 @@ +use std::collections::BTreeMap; + +use crate::model::{ + CERTIFICATIONS, Caregiver, CaregiverId, Outcome, REGIONS, State, UnfilledReason, Visit, + VisitId, travel_minutes, +}; + +pub type PublishedAssignments = BTreeMap; + +pub fn validate( + state: &State, + outcomes: &BTreeMap, + protected: Option<(&PublishedAssignments, VisitId)>, +) -> Result<(), String> { + let active = state + .visits + .values() + .filter(|visit| !visit.canceled) + .count(); + if outcomes.len() != active { + return Err(format!( + "expected one outcome per active visit: {active} active, {} outcomes", + outcomes.len() + )); + } + + let mut assigned: BTreeMap> = BTreeMap::new(); + for (visit_id, outcome) in outcomes { + let visit = state + .visits + .get(visit_id) + .ok_or_else(|| format!("outcome references missing visit {visit_id}"))?; + if visit.canceled { + return Err(format!("canceled visit {visit_id} still has an outcome")); + } + if let Outcome::Assigned(caregiver_id) = outcome { + assigned.entry(*caregiver_id).or_default().push(visit); + } + } + + for (caregiver_id, visits) in &mut assigned { + let caregiver = state + .caregivers + .get(caregiver_id) + .ok_or_else(|| format!("assignment references missing caregiver {caregiver_id}"))?; + visits.sort_by_key(|visit| (visit.start, visit.id)); + let minutes: i64 = visits.iter().map(|visit| visit.duration()).sum(); + if minutes > caregiver.max_minutes { + return Err(format!("caregiver {caregiver_id} exceeds hour limit")); + } + for visit in visits.iter() { + validate_static(caregiver, visit)?; + } + for pair in visits.windows(2) { + let first = pair[0]; + let second = pair[1]; + let earliest = first.end + travel_minutes(first.region, second.region); + if earliest > second.start { + return Err(format!( + "caregiver {caregiver_id} lacks travel time between {} and {}", + first.id, second.id + )); + } + } + } + + for (visit_id, outcome) in outcomes { + if let Outcome::Unfilled(reported) = outcome { + let visit = &state.visits[visit_id]; + let expected = independent_reason(state, &assigned, visit)?; + if *reported != expected { + return Err(format!( + "visit {visit_id}: reason {} did not match validator reason {}", + reported.code(), + expected.code() + )); + } + } + } + + if let Some((before, changed_visit)) = protected { + for (visit_id, caregiver_id) in before { + if *visit_id == changed_visit || state.visits[visit_id].canceled { + continue; + } + if outcomes.get(visit_id) != Some(&Outcome::Assigned(*caregiver_id)) { + return Err(format!( + "unaffected published assignment for visit {visit_id} changed" + )); + } + } + } + Ok(()) +} + +fn validate_static(caregiver: &Caregiver, visit: &Visit) -> Result<(), String> { + if visit.required_certification >= CERTIFICATIONS + || caregiver.certification_mask & (1 << visit.required_certification) == 0 + { + return Err(format!("visit {} lacks certification", visit.id)); + } + if visit.region >= REGIONS || caregiver.region_mask & (1 << visit.region) == 0 { + return Err(format!("visit {} lacks region coverage", visit.id)); + } + if !caregiver + .availability + .iter() + .any(|window| window.start <= visit.start && visit.end <= window.end) + { + return Err(format!("visit {} is outside availability", visit.id)); + } + if caregiver + .required_rest + .iter() + .any(|rest| rest.start < visit.end && visit.start < rest.end) + { + return Err(format!("visit {} overlaps required rest", visit.id)); + } + Ok(()) +} + +fn independent_reason( + state: &State, + assigned: &BTreeMap>, + visit: &Visit, +) -> Result { + let mut candidates: Vec<_> = state.caregivers.values().collect(); + candidates.retain(|caregiver| { + visit.required_certification < CERTIFICATIONS + && caregiver.certification_mask & (1 << visit.required_certification) != 0 + }); + if candidates.is_empty() { + return Ok(UnfilledReason::NoCertification); + } + candidates.retain(|caregiver| { + visit.region < REGIONS && caregiver.region_mask & (1 << visit.region) != 0 + }); + if candidates.is_empty() { + return Ok(UnfilledReason::NoRegionCoverage); + } + candidates.retain(|caregiver| { + caregiver + .availability + .iter() + .any(|window| window.start <= visit.start && visit.end <= window.end) + }); + if candidates.is_empty() { + return Ok(UnfilledReason::OutsideAvailability); + } + candidates.retain(|caregiver| { + !caregiver + .required_rest + .iter() + .any(|rest| rest.start < visit.end && visit.start < rest.end) + }); + if candidates.is_empty() { + return Ok(UnfilledReason::RequiredRest); + } + candidates.retain(|caregiver| { + let minutes: i64 = assigned + .get(&caregiver.id) + .into_iter() + .flatten() + .map(|assigned_visit| assigned_visit.duration()) + .sum(); + minutes + visit.duration() <= caregiver.max_minutes + }); + if candidates.is_empty() { + return Ok(UnfilledReason::HourLimit); + } + for caregiver in candidates { + let travel_feasible = assigned + .get(&caregiver.id) + .into_iter() + .flatten() + .all(|existing| { + if existing.end <= visit.start { + existing.end + travel_minutes(existing.region, visit.region) <= visit.start + } else if visit.end <= existing.start { + visit.end + travel_minutes(visit.region, existing.region) <= existing.start + } else { + false + } + }); + if travel_feasible { + return Err(format!( + "visit {} is unfilled but caregiver {} is independently eligible", + visit.id, caregiver.id + )); + } + } + Ok(UnfilledReason::TravelConflict) +} diff --git a/developer-simulation/runs/2026-07-31--parts-catalog-evolution/Cargo.toml b/developer-simulation/runs/2026-07-31--parts-catalog-evolution/Cargo.toml new file mode 100644 index 0000000..d3f2967 --- /dev/null +++ b/developer-simulation/runs/2026-07-31--parts-catalog-evolution/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "parts-catalog-evolution" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +axum = "0.8.9" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.150" +tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "sync"] } +tower = { version = "0.5.3", features = ["util"] } + +[dev-dependencies] +http-body-util = "0.1" diff --git a/developer-simulation/runs/2026-07-31--parts-catalog-evolution/EVIDENCE.md b/developer-simulation/runs/2026-07-31--parts-catalog-evolution/EVIDENCE.md new file mode 100644 index 0000000..03ab527 --- /dev/null +++ b/developer-simulation/runs/2026-07-31--parts-catalog-evolution/EVIDENCE.md @@ -0,0 +1,272 @@ +# Evidence + +Run date: 2026-07-31 (America/New_York) + +## Outcome + +The prototype outcome is **BogKit no-fit for the authoritative catalog path**. The smallest credible solution keeps SQLite authoritative and uses ordinary transactional tables and compact facet indexes. Fold's incremental views are promising for optional derived analytics later, but adopting them here would introduce a second durable store without solving schema validation, conditional updates, SQLite-file preservation, or online category revisions. + +ESE and ANNy are also no-fit because embeddings, semantic search, and recommendations are explicit non-goals. + +## Ordered discovery and friction + +1. `sed -n '1,240p' README.md` + - The top-level description says Fold incrementally maintains fast views and names ESE/ANNy, but gives no catalog, SQLite coexistence, schema evolution, or optimistic-update guidance. +2. Read the four public examples under `examples/`. + - `starter` demonstrates durable counts/bags and atomic retraction. + - `timeseries` demonstrates typed, fixed-at-compile-time materialized views. + - `chat` demonstrates one thread owning Fold and publishing snapshots to Axum clients. + - `search` demonstrates keyed upsert/retraction plus BM25/HNSW derived indexes. +3. Read Fold's public crate documentation and relevant public operators. + - Fold persists through Fjall, not the existing SQLite file. Pipelines are concrete Rust types assembled at startup. This is a direct mismatch for SQLite authority and user-defined category schemas activated online. +4. Evaluated the baseline before choosing a component. + - SQLite already provides atomic compare-and-swap updates, online additive tables/indexes, WAL restart safety, parameterized exact/range queries, and atomic import checkpoints in the same file. +5. Initial dependency check found no cached `rusqlite` package in the sanitized workspace. + - Smallest improvement made in the prototype: a narrow parameterized wrapper around the system SQLite library in `src/sqlite.rs`. This added implementation friction and is not a recommendation to replace `rusqlite` in production. +6. First compile found two expressions unsupported directly inside `serde_json::json!`. + - Moved the selected seed values into local variables. Tests then compiled and passed. +7. First test run reported two unused SQLite functions. + - Removed them; the required lint command then completed with no warnings. +8. Scale verification was run first on 25,000 records, then repeated at the full 250,000-record boundary. +9. Skeptical review found that import jobs were bound only to a record count and + `INSERT OR IGNORE` allowed conflicting pre-existing IDs to advance the + checkpoint. Independent reproducers produced mixed payload sizes and a false + completion. The importer now persists a generator/payload fingerprint, + rejects changed sources, compares the complete expected product on duplicate + IDs, and rolls back before checkpoint advancement on conflict. +10. Review narrowed the HTTP, concurrency, schema, interruption, performance, + and storage claims and removed the unused optional Fold dependency. + +## Exact verification and observed results + +### Formatting + +Command: + +```console +cargo fmt --check +``` + +Observed: exit 0, no output. + +### Tests + +Command: + +```console +CARGO_NET_OFFLINE=true cargo test +``` + +Observed: + +```text +running 9 tests +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +``` + +The tests cover stable nested error paths, omitted-field patch behavior, stale +versions, v1 readability and v1-to-v2 migration, exact/range agreement with an +independent evaluator, orderly import reopen with every expected ID, rejection +of changed resume sources and conflicting pre-existing rows, POST/GET response +shape within this prototype, and preservation of an unrelated legacy table. + +### Lint + +Command: + +```console +CARGO_NET_OFFLINE=true cargo clippy --all-targets -- -D warnings +``` + +Observed: exit 0; finished with no warnings. + +### Demo + +Command: + +```console +CARGO_NET_OFFLINE=true cargo run --release -- demo +``` + +Observed: + +```text +legacy SQLite table preserved: yes +v1 readable after v2 activation: spec_version=1 +tested migration: spec_version=2, version=2 +partial patch preserved connector="usb-c", new version=2 +stale conditional patch rejected: true +independent filter evaluator: exact=1, range=1, all matched +Fold decision: no-fit for authoritative catalog path; SQLite retained +``` + +### Full orderly reopen and resume + +Command: + +```console +CARGO_NET_OFFLINE=true cargo run --release -- import-check 250000 2048 +``` + +Observed: + +```text +simulated interruption checkpoint: 7000/250000 +resume completed: true +rows: 250000; distinct ids: 250000; expected: 250000 +record payload: 2048 bytes; database: 1139924992 bytes +elapsed: 8.623s +``` + +The command drops the database connection after the first seven committed +batches, reopens the same SQLite file, validates the source fingerprint, resumes +by durable ordinal, and verifies total and distinct IDs. This is an orderly +reopen after committed batches, not a crash or power-loss test. The unit test +additionally checks the complete expected ID set on a 2,503-row uneven final +batch, changed-source rejection, and duplicate-content conflict rollback. + +### Full-population 150-request burst + +Command: + +```console +CARGO_NET_OFFLINE=true cargo run --release -- burst 250000 +``` + +Observed: + +```text +simultaneous burst: 150 requests (120 reads / 30 writes) +seed: 250000 representative 2 KiB records +read p95: 4.037 ms +write p95: 2.314 ms +targets: reads <50 ms; writes <100 ms +``` + +This is an in-process Axum burst against the full population on the trial host. +One global mutex serializes SQLite access. Two reviewer reruns measured read +p95 at 1.903 and 1.518 ms and write p95 at 1.615 and 1.389 ms. The post-fix run +measured 2.104 and 2.721 ms, making the observed ranges 1.518–4.037 ms and +1.389–2.721 ms. The test excludes network latency, a second process, a +same-version write race, and a memory-limited VM. + +### Full-population storage comparison + +Command: + +```console +CARGO_NET_OFFLINE=true cargo run --release -- storage-check 250000 2048 +``` + +Observed: + +```text +storage sample: 250000 records at 2048 payload bytes +baseline SQLite: 1039552512 bytes +indexed SQLite: 1139924992 bytes +ratio: 1.097x; target: <1.5x +``` + +The baseline contains identical product rows with primary-key, category, and price indexes. The indexed version adds category schemas, import progress, per-product scalar facets, and exact/range indexes. + +## Acceptance coverage + +| Requirement | Evidence | Status | +|---|---|---| +| CRUD compatibility | Axum POST/GET route smoke test plus direct patch/filter/delete behavior | Only the prototype shape is exercised; the real service contract was unavailable | +| Stable nested validation paths | `specs.battery_wh` test | Demonstrated | +| Safe partial updates | omitted connector/name preserved; stale version gives 409 | Demonstrated | +| Exact/range correctness | SQLite results compared to separate JSON evaluator across three query shapes | Demonstrated on generated data | +| v1 readable after v2 | read old row after activating v2; explicit migration adds `battery_wh` | Demonstrated | +| p95 targets at burst | 1.518–4.037 ms reads, 1.389–2.721 ms writes at 150 in-process requests | Passed under one global mutex on the trial host, not a 512 MiB VM | +| Reopened 250,000 import | orderly close, reopen, source check, resume; total = distinct = 250,000 | Demonstrated at full count with 2 KiB payloads; no crash injection | +| Storage under 1.5x | 1.097x against a synthetic SQLite baseline | Demonstrated at full count with 2 KiB payloads and three categories | +| Existing SQLite preserved | unrelated table survives schema initialization | Demonstrated | +| Category revision | active schema flip is transactional while v1 stays readable | Demonstrated only for hardcoded laptop v1/v2 | + +## Categorized findings + +### 1. Storage integration — high severity, high confidence + +- Reproduction: top-level and Fold crate documentation identify Fjall as Fold's durable store; public examples open a Fold-owned database path. +- Finding: Fold is not an extension of an existing SQLite transaction. Making it authoritative would break the preserve-SQLite constraint; making it derived introduces untested dual-write/rebuild responsibilities. Sidecar storage cost was not measured. +- Smallest improvement: document this boundary prominently in the top-level README, including a supported SQLite-derived-view synchronization pattern if one exists. + +### 2. Dynamic category schemas — high severity, high confidence + +- Reproduction: examples build typed pipelines from Rust closures and concrete record types at startup. +- Finding: that model is strong for known static records but does not directly validate 30 user-revised category schemas online with stable JSON paths. +- Smallest improvement: add an explicit no-fit example or guide for runtime-defined schemas, and identify the intended external validation layer. + +### 3. Conditional updates — high severity, high confidence + +- Reproduction: keyed upsert retracts the prior value, but the examples expose no expected-version condition. +- Finding: safe concurrent editing still needs a compare-and-swap check in the authoritative transaction. +- Smallest improvement: document an expected-version keyed update recipe or add a conditional-upsert result type. + +### 4. Query fit — medium severity, high confidence + +- Reproduction: Ranked provides range traversal for one compile-time score; filtering operators materialize predicates chosen at pipeline construction. +- Finding: 30 evolving categories create many runtime paths and combinations; a compact SQLite facet table is simpler for the required exact/range subset. +- Smallest improvement: provide guidance on dynamic facet indexing and storage amplification, including a benchmark against a relational baseline. + +### 5. Onboarding — medium severity, high confidence + +- Reproduction: the top-level README describes Fold in one paragraph and routes users to internal docs; examples contain the clearest operational explanations. +- Finding: a new developer must infer transaction ownership, storage format, schema stability expectations, and migration boundaries from source and examples. +- Smallest improvement: add a short “fits / does not fit” matrix covering source-of-truth storage, runtime schemas, query types, and update concurrency. + +### 6. Local SQLite wrapper — medium severity, high confidence + +- Reproduction: `CARGO_NET_OFFLINE=true cargo info rusqlite@0.37.0` reported that the package was unavailable in the sanitized registry cache. +- Finding: the trial needed a small direct wrapper to stay runnable offline. It is appropriately narrow but less mature than `rusqlite`. +- Smallest improvement: productionize with a maintained SQLite crate, pooled read connections, migration tooling, and error-code mapping. + +### 7. Import identity defect, fixed — high severity, high confidence + +- Reproduction: the reviewer resumed one job with a changed payload size and + inserted a conflicting `bulk-000000`; the original code accepted both and + advanced the checkpoint. +- Finding: count-only job identity and unchecked `INSERT OR IGNORE` could report + a mixed or conflicting import as complete. This was a prototype defect, not a + BogKit defect. +- Smallest improvement: persist a source fingerprint and compare complete + duplicate content before advancing a checkpoint. Both regressions now pass. + +## Decision audit + +| Option | Decision | Reason | +|---|---|---| +| Keep hand-written optional columns | Rejected | Repeats category conditionals and migrations | +| Unvalidated JSON only | Rejected | Cannot provide reliable validation, filtering, or safe patch semantics | +| Fold as the authority | Rejected, no-fit | Does not preserve the existing SQLite file | +| SQLite authority plus Fold sidecar views | Rejected for this slice | Dual-store recovery is unproven and unnecessary for the tested exact/range filters; sidecar storage was not measured | +| ESE or ANNy | Rejected, no-fit | Search/recommendations are non-goals | +| SQLite products + schema registry + compact facets | Chosen | Meets the compact boundary in one crash-consistent file with measured headroom | + +## Unresolved uncertainty + +- The real service's exact HTTP response and error shapes were not present. The + route-level smoke test covers POST/GET only; other operations are exercised + directly or in the demo. +- The burst ran on the trial host, not under a 512 MiB cgroup or VM; peak resident memory was not measured. +- Records use deterministic 2 KiB descriptions, the low end of 2–20 KiB. Larger mixed-size payload behavior was not measured. +- The compact boundary uses three categories, not all 30; schema rules are + handwritten rather than runtime-defined. +- The “interruption” is an orderly connection close at a committed checkpoint, + not a forced process exit or power loss during an SQLite commit. SQLite WAL + durability itself was not fault-injected. +- One global mutex serializes database access. Same-version races and + multi-process concurrency were not tested. +- Only scalar nested specification fields are indexed. Arrays or deeper objects would need a declared canonical facet policy. +- The direct SQLite wrapper is intentionally prototype-sized; production should use a maintained binding and operational migration tooling. + +## Files for coordinator review + +- `README.md` — reproduction, component decision, API examples, and honest scope. +- `EVIDENCE.md` — this trial record. +- `src/main.rs` — catalog, routes, generator, evaluator, import, measurements, and tests. +- `src/sqlite.rs` — narrow system-SQLite wrapper; highest implementation-risk file. +- `Cargo.toml` — nested-workspace dependencies; no BogKit component dependency. + +No BogKit core or public example files were modified. diff --git a/developer-simulation/runs/2026-07-31--parts-catalog-evolution/README.md b/developer-simulation/runs/2026-07-31--parts-catalog-evolution/README.md new file mode 100644 index 0000000..d86e8c2 --- /dev/null +++ b/developer-simulation/runs/2026-07-31--parts-catalog-evolution/README.md @@ -0,0 +1,87 @@ +# Wholesale catalog trial B + +This is a standalone Rust prototype for a three-category wholesale catalog. It keeps the existing SQLite file as the source of truth and adds: + +- Axum create, read, update, delete, and filter routes; +- three hardcoded category validators with laptop revision activation; +- stable nested validation errors such as `specs.battery_wh`; +- version-checked partial updates that preserve omitted fields; +- exact and numeric-range indexes checked against a separate in-memory evaluator; +- explicit laptop schema migration from revision 1 to revision 2; and +- checkpointed imports that bind each job to a source fingerprint, verify + duplicate content, and resume after an orderly database reopen. + +## Component decision + +Fold, ESE, and ANNy are deliberately not used in the runtime path. Fold's +durable incremental views use a separate Fjall store. That is useful for +derived views, but this brief requires preserving SQLite as the authority. A +sidecar's synchronization and storage cost were not measured. ESE and ANNy +solve embedding and nearest-neighbor search, which are non-goals here. No +BogKit dependency is included. + +## Exact reproduction + +Run from this directory: + +```console +cargo fmt --check +CARGO_NET_OFFLINE=true cargo test +CARGO_NET_OFFLINE=true cargo clippy --all-targets -- -D warnings +CARGO_NET_OFFLINE=true cargo run --release -- demo +CARGO_NET_OFFLINE=true cargo run --release -- import-check 250000 2048 +CARGO_NET_OFFLINE=true cargo run --release -- burst 250000 +CARGO_NET_OFFLINE=true cargo run --release -- storage-check 250000 2048 +``` + +The first build uses dependencies already available with BogKit and requires a +linkable system `sqlite3` library. Generated databases stay under +`target/trial-data`. + +## Run the HTTP API + +```console +CARGO_NET_OFFLINE=true cargo run --release -- serve target/trial-data/server.sqlite +``` + +In another terminal: + +```console +curl -sS http://127.0.0.1:3000/products \ + -H 'content-type: application/json' \ + -d '{"id":"c-1","category":"cable","name":"USB-C cable","price_cents":1299,"description":"2 KiB records are used by the scale checks","specs":{"length_m":2.0,"connector":"usb-c"},"compatibility":[{"system":"inventory","model":"v1"}],"tags":["wholesale"]}' + +curl -sS 'http://127.0.0.1:3000/products?exact_path=specs.connector&exact_value=usb-c' + +curl -sS -X PATCH http://127.0.0.1:3000/products/c-1 \ + -H 'content-type: application/json' \ + -d '{"expected_version":1,"specs":{"length_m":3.5}}' + +curl -sS -X DELETE 'http://127.0.0.1:3000/products/c-1?expected_version=2' +``` + +Responses retain the product fields used by this prototype. The route-level +smoke test covers POST and GET. PATCH, filtering, stale-version rejection, +validation, and DELETE behavior are implemented and exercised directly or by +the demo, but are not claimed compatible with an unavailable real-service +contract. + +## Compact boundary and honest limits + +The generator covers laptop, cable, and chair records, including common +scalars, nested category specifications, one compatibility entry, tags, and a +deterministic 2 KiB description. The validators are hardcoded in Rust; this is +not general runtime-defined schema support. Laptop schemas have two revisions. +The scale commands use all 250,000 records at the low end of the requested +2–20 KiB range. + +The burst test sends 150 simultaneous in-process HTTP requests, with 120 reads +and 30 writes, against the full population. One global mutex serializes SQLite +access. The test does not emulate network latency, exercise a second process or +same-version write race, or enforce a 512 MiB process limit. Across the author, +reviewer, and post-fix runs, read p95 was 1.518–4.037 ms and write p95 was +1.389–2.721 ms. The storage comparison uses a synthetic baseline SQLite +database with the same 2 KiB product rows and ordinary category/price indexes, +then compares it with the schema/facet-indexed database. + +See `EVIDENCE.md` for observed results, friction, the decision audit, and remaining uncertainty. diff --git a/developer-simulation/runs/2026-07-31--parts-catalog-evolution/src/main.rs b/developer-simulation/runs/2026-07-31--parts-catalog-evolution/src/main.rs new file mode 100644 index 0000000..7841489 --- /dev/null +++ b/developer-simulation/runs/2026-07-31--parts-catalog-evolution/src/main.rs @@ -0,0 +1,1517 @@ +mod sqlite; + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use axum::extract::{Path as AxumPath, Query, State}; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use sqlite::{Connection, Step}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct Compatibility { + system: String, + model: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct ProductInput { + id: String, + category: String, + name: String, + price_cents: i64, + #[serde(default)] + description: String, + specs: Value, + #[serde(default)] + compatibility: Vec, + #[serde(default)] + tags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct Product { + id: String, + version: i64, + category: String, + name: String, + price_cents: i64, + #[serde(default)] + description: String, + spec_version: i64, + specs: Value, + compatibility: Vec, + tags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct ProductPatch { + expected_version: i64, + name: Option, + price_cents: Option, + description: Option, + specs: Option, + compatibility: Option>, + tags: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ApiError { + error: String, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, +} + +#[derive(Debug)] +struct Failure { + status: StatusCode, + body: ApiError, +} + +type Result = std::result::Result; + +impl Failure { + fn invalid(path: impl Into, message: impl Into) -> Self { + Self { + status: StatusCode::UNPROCESSABLE_ENTITY, + body: ApiError { + error: message.into(), + path: Some(path.into()), + }, + } + } + + fn not_found(id: &str) -> Self { + Self { + status: StatusCode::NOT_FOUND, + body: ApiError { + error: format!("product {id} not found"), + path: None, + }, + } + } + + fn conflict(expected: i64, actual: i64) -> Self { + Self { + status: StatusCode::CONFLICT, + body: ApiError { + error: format!("stale version: expected {expected}, current is {actual}"), + path: Some("expected_version".into()), + }, + } + } + + fn internal(error: impl std::fmt::Display) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + body: ApiError { + error: error.to_string(), + path: None, + }, + } + } +} + +impl From for Failure { + fn from(value: sqlite::Error) -> Self { + Self::internal(value) + } +} + +type HttpError = (StatusCode, Json); + +impl From for HttpError { + fn from(value: Failure) -> Self { + (value.status, Json(value.body)) + } +} + +struct Catalog { + db: Connection, +} + +impl Catalog { + fn open(path: &Path) -> Result { + let db = Connection::open(path)?; + db.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + PRAGMA foreign_keys=ON; + PRAGMA busy_timeout=5000; + CREATE TABLE IF NOT EXISTS category_schemas ( + category TEXT NOT NULL, revision INTEGER NOT NULL, + active INTEGER NOT NULL DEFAULT 0, schema_json TEXT NOT NULL, + PRIMARY KEY(category, revision) + ); + CREATE UNIQUE INDEX IF NOT EXISTS one_active_schema + ON category_schemas(category) WHERE active=1; + CREATE TABLE IF NOT EXISTS products ( + id TEXT PRIMARY KEY, version INTEGER NOT NULL, + category TEXT NOT NULL, name TEXT NOT NULL, + price_cents INTEGER NOT NULL, description TEXT NOT NULL DEFAULT '', + spec_version INTEGER NOT NULL, specs_json TEXT NOT NULL, + compatibility_json TEXT NOT NULL, tags_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS products_category ON products(category); + CREATE INDEX IF NOT EXISTS products_price ON products(price_cents); + CREATE TABLE IF NOT EXISTS product_facets ( + product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE, + path TEXT NOT NULL, text_value TEXT, num_value REAL, + PRIMARY KEY(product_id, path) + ); + CREATE INDEX IF NOT EXISTS facets_text ON product_facets(path, text_value, product_id); + CREATE INDEX IF NOT EXISTS facets_num ON product_facets(path, num_value, product_id); + CREATE TABLE IF NOT EXISTS import_jobs ( + job_id TEXT PRIMARY KEY, next_ordinal INTEGER NOT NULL, + total INTEGER NOT NULL, source_fingerprint TEXT NOT NULL, + completed INTEGER NOT NULL DEFAULT 0 + );", + )?; + Ok(Self { db }) + } + + fn install_demo_schemas(&self, laptop_revision: i64) -> Result<()> { + for (category, revision, schema) in [ + ( + "laptop", + 1, + r#"{"ram_gb":"integer 4..128","screen_inches":"number 10..20"}"#, + ), + ( + "laptop", + 2, + r#"{"ram_gb":"integer 4..128","screen_inches":"number 10..20","battery_wh":"integer 20..150"}"#, + ), + ( + "cable", + 1, + r#"{"length_m":"number 0..100","connector":"usb-c|hdmi|ethernet"}"#, + ), + ( + "chair", + 1, + r#"{"max_weight_kg":"integer 40..300","adjustable":"boolean"}"#, + ), + ] { + let mut statement = self.db.prepare( + "INSERT OR IGNORE INTO category_schemas(category,revision,active,schema_json) + VALUES(?,?,0,?)", + )?; + statement.bind_text(1, category)?; + statement.bind_i64(2, revision)?; + statement.bind_text(3, schema)?; + statement.execute()?; + } + for category in ["laptop", "cable", "chair"] { + let revision = if category == "laptop" { + laptop_revision + } else { + 1 + }; + self.activate_schema(category, revision)?; + } + Ok(()) + } + + fn activate_schema(&self, category: &str, revision: i64) -> Result<()> { + self.db.transaction(|| { + let mut off = self + .db + .prepare("UPDATE category_schemas SET active=0 WHERE category=?")?; + off.bind_text(1, category)?; + off.execute()?; + let mut on = self + .db + .prepare("UPDATE category_schemas SET active=1 WHERE category=? AND revision=?")?; + on.bind_text(1, category)?; + on.bind_i64(2, revision)?; + on.execute()?; + if self.db.changes() != 1 { + return Err(sqlite::Error(format!( + "missing schema {category} revision {revision}" + ))); + } + Ok(()) + })?; + Ok(()) + } + + fn active_revision(&self, category: &str) -> Result { + let mut statement = self + .db + .prepare("SELECT revision FROM category_schemas WHERE category=? AND active=1")?; + statement.bind_text(1, category)?; + match statement.step()? { + Step::Row => Ok(statement.column_i64(0)), + Step::Done => Err(Failure::invalid("category", "unknown category")), + } + } + + fn create(&self, input: ProductInput) -> Result { + let revision = self.active_revision(&input.category)?; + self.create_at_revision(input, revision) + } + + fn create_at_revision(&self, input: ProductInput, revision: i64) -> Result { + validate_input(&input, revision)?; + let product = Product { + id: input.id, + version: 1, + category: input.category, + name: input.name, + price_cents: input.price_cents, + description: input.description, + spec_version: revision, + specs: input.specs, + compatibility: input.compatibility, + tags: input.tags, + }; + self.db + .transaction(|| self.insert_raw(&product, false).map(|_| ()))?; + Ok(product) + } + + fn insert_raw(&self, product: &Product, ignore_duplicate: bool) -> sqlite::Result { + let verb = if ignore_duplicate { "OR IGNORE " } else { "" }; + let sql = format!( + "INSERT {verb}INTO products + (id,version,category,name,price_cents,description,spec_version,specs_json,compatibility_json,tags_json) + VALUES(?,?,?,?,?,?,?,?,?,?)" + ); + let mut statement = self.db.prepare(&sql)?; + statement.bind_text(1, &product.id)?; + statement.bind_i64(2, product.version)?; + statement.bind_text(3, &product.category)?; + statement.bind_text(4, &product.name)?; + statement.bind_i64(5, product.price_cents)?; + statement.bind_text(6, &product.description)?; + statement.bind_i64(7, product.spec_version)?; + statement.bind_text(8, &serde_json::to_string(&product.specs).unwrap())?; + statement.bind_text(9, &serde_json::to_string(&product.compatibility).unwrap())?; + statement.bind_text(10, &serde_json::to_string(&product.tags).unwrap())?; + statement.execute()?; + let inserted = self.db.changes() == 1; + if !ignore_duplicate || inserted { + self.replace_facets(product)?; + } + Ok(inserted) + } + + fn replace_facets(&self, product: &Product) -> sqlite::Result<()> { + let mut delete = self + .db + .prepare("DELETE FROM product_facets WHERE product_id=?")?; + delete.bind_text(1, &product.id)?; + delete.execute()?; + let object = product.specs.as_object().expect("validated specs object"); + for (name, value) in object { + let path = format!("specs.{name}"); + let mut insert = self.db.prepare( + "INSERT INTO product_facets(product_id,path,text_value,num_value) + VALUES(?,?,?,?)", + )?; + insert.bind_text(1, &product.id)?; + insert.bind_text(2, &path)?; + match value { + Value::String(text) => { + insert.bind_text(3, text)?; + insert.bind_null(4)?; + } + Value::Number(number) => { + insert.bind_null(3)?; + insert.bind_f64(4, number.as_f64().unwrap())?; + } + Value::Bool(value) => { + insert.bind_text(3, if *value { "true" } else { "false" })?; + insert.bind_null(4)?; + } + _ => continue, + } + insert.execute()?; + } + Ok(()) + } + + fn get(&self, id: &str) -> Result { + self.get_raw(id)?.ok_or_else(|| Failure::not_found(id)) + } + + fn get_raw(&self, id: &str) -> sqlite::Result> { + let mut statement = self.db.prepare( + "SELECT id,version,category,name,price_cents,description,spec_version, + specs_json,compatibility_json,tags_json FROM products WHERE id=?", + )?; + statement.bind_text(1, id)?; + match statement.step()? { + Step::Row => row_product(&statement).map(Some).map_err(sqlite::Error), + Step::Done => Ok(None), + } + } + + fn patch(&self, id: &str, patch: ProductPatch) -> Result { + self.db + .transaction(|| { + let mut product = self.get(id).map_err(|e| sqlite::Error(e.body.error))?; + if product.version != patch.expected_version { + return Err(sqlite::Error(format!( + "STALE:{}:{}", + patch.expected_version, product.version + ))); + } + if let Some(name) = patch.name { + product.name = name; + } + if let Some(price) = patch.price_cents { + product.price_cents = price; + } + if let Some(description) = patch.description { + product.description = description; + } + if let Some(spec_patch) = patch.specs { + merge_specs(&mut product.specs, spec_patch).map_err(|e| { + sqlite::Error(format!( + "INVALID:{}:{}", + e.body.path.unwrap_or_default(), + e.body.error + )) + })?; + } + if let Some(compatibility) = patch.compatibility { + product.compatibility = compatibility; + } + if let Some(tags) = patch.tags { + product.tags = tags; + } + validate_product(&product).map_err(|e| { + sqlite::Error(format!( + "INVALID:{}:{}", + e.body.path.unwrap_or_default(), + e.body.error + )) + })?; + product.version += 1; + let mut update = self.db.prepare( + "UPDATE products SET version=?,name=?,price_cents=?,description=?,specs_json=?, + compatibility_json=?,tags_json=? WHERE id=? AND version=?", + )?; + update.bind_i64(1, product.version)?; + update.bind_text(2, &product.name)?; + update.bind_i64(3, product.price_cents)?; + update.bind_text(4, &product.description)?; + update.bind_text(5, &serde_json::to_string(&product.specs).unwrap())?; + update.bind_text(6, &serde_json::to_string(&product.compatibility).unwrap())?; + update.bind_text(7, &serde_json::to_string(&product.tags).unwrap())?; + update.bind_text(8, id)?; + update.bind_i64(9, patch.expected_version)?; + update.execute()?; + if self.db.changes() != 1 { + return Err(sqlite::Error("concurrent update lost race".into())); + } + self.replace_facets(&product)?; + Ok(product) + }) + .map_err(|error| decode_transaction_error(error, id)) + } + + fn delete(&self, id: &str, expected_version: i64) -> Result { + let product = self.get(id)?; + if product.version != expected_version { + return Err(Failure::conflict(expected_version, product.version)); + } + let mut statement = self + .db + .prepare("DELETE FROM products WHERE id=? AND version=?")?; + statement.bind_text(1, id)?; + statement.bind_i64(2, expected_version)?; + statement.execute()?; + if self.db.changes() != 1 { + return Err(Failure::conflict(expected_version, self.get(id)?.version)); + } + Ok(product) + } + + fn migrate_laptop_v1_to_v2(&self, id: &str, default_battery_wh: i64) -> Result { + let mut product = self.get(id)?; + if product.category != "laptop" || product.spec_version != 1 { + return Err(Failure::invalid( + "spec_version", + "expected laptop revision 1", + )); + } + product + .specs + .as_object_mut() + .unwrap() + .insert("battery_wh".into(), json!(default_battery_wh)); + product.spec_version = 2; + validate_product(&product)?; + let old_version = product.version; + product.version += 1; + self.db.transaction(|| { + let mut update = self.db.prepare( + "UPDATE products SET version=?,spec_version=2,specs_json=? WHERE id=? AND version=?", + )?; + update.bind_i64(1, product.version)?; + update.bind_text(2, &serde_json::to_string(&product.specs).unwrap())?; + update.bind_text(3, id)?; + update.bind_i64(4, old_version)?; + update.execute()?; + if self.db.changes() != 1 { + return Err(sqlite::Error("migration version race".into())); + } + self.replace_facets(&product) + })?; + Ok(product) + } + + fn filter(&self, query: &FilterQuery) -> Result> { + let mut sql = String::from( + "SELECT id,version,category,name,price_cents,description,spec_version, + specs_json,compatibility_json,tags_json FROM products p WHERE 1=1", + ); + if query.category.is_some() { + sql.push_str(" AND p.category=?"); + } + if query.exact_path.is_some() { + sql.push_str( + " AND EXISTS (SELECT 1 FROM product_facets f WHERE f.product_id=p.id + AND f.path=? AND (f.text_value=? OR f.num_value=?))", + ); + } + if query.range_path.is_some() { + sql.push_str( + " AND EXISTS (SELECT 1 FROM product_facets r WHERE r.product_id=p.id + AND r.path=? AND (? IS NULL OR r.num_value>=?) + AND (? IS NULL OR r.num_value<=?))", + ); + } + sql.push_str(" ORDER BY p.id"); + let mut statement = self.db.prepare(&sql)?; + let mut index = 1; + if let Some(category) = &query.category { + statement.bind_text(index, category)?; + index += 1; + } + if let Some(path) = &query.exact_path { + let value = query.exact_value.as_deref().unwrap_or(""); + statement.bind_text(index, path)?; + statement.bind_text(index + 1, value)?; + if let Ok(number) = value.parse::() { + statement.bind_f64(index + 2, number)?; + } else { + statement.bind_null(index + 2)?; + } + index += 3; + } + if let Some(path) = &query.range_path { + statement.bind_text(index, path)?; + bind_optional_f64(&mut statement, index + 1, query.min)?; + bind_optional_f64(&mut statement, index + 2, query.min)?; + bind_optional_f64(&mut statement, index + 3, query.max)?; + bind_optional_f64(&mut statement, index + 4, query.max)?; + } + let mut products = Vec::new(); + while matches!(statement.step()?, Step::Row) { + products.push(row_product(&statement).map_err(Failure::internal)?); + } + Ok(products) + } + + fn all(&self) -> Result> { + self.filter(&FilterQuery::default()) + } + + fn resume_import( + &self, + job_id: &str, + total: usize, + batch_size: usize, + stop_after_batches: Option, + payload_bytes: usize, + ) -> Result { + let source_fingerprint = format!("catalog-seed:v1:payload-bytes={payload_bytes}"); + let mut progress = self.import_progress(job_id)?.unwrap_or(ImportProgress { + next_ordinal: 0, + total, + source_fingerprint: source_fingerprint.clone(), + completed: false, + }); + if progress.total != total { + return Err(Failure::invalid("total", "import total changed on resume")); + } + if progress.source_fingerprint != source_fingerprint { + return Err(Failure::invalid( + "source_fingerprint", + "import source changed on resume", + )); + } + let mut batches = 0; + while progress.next_ordinal < total { + let end = (progress.next_ordinal + batch_size).min(total); + self.db.transaction(|| { + for ordinal in progress.next_ordinal..end { + let product = generated_product(ordinal, payload_bytes); + if !self.insert_raw(&product, true)? + && self.get_raw(&product.id)?.as_ref() != Some(&product) + { + return Err(sqlite::Error(format!( + "import product {} conflicts with existing content", + product.id + ))); + } + } + let mut checkpoint = self.db.prepare( + "INSERT INTO import_jobs + (job_id,next_ordinal,total,source_fingerprint,completed) VALUES(?,?,?,?,?) + ON CONFLICT(job_id) DO UPDATE SET next_ordinal=excluded.next_ordinal, + total=excluded.total,source_fingerprint=excluded.source_fingerprint, + completed=excluded.completed", + )?; + checkpoint.bind_text(1, job_id)?; + checkpoint.bind_i64(2, end as i64)?; + checkpoint.bind_i64(3, total as i64)?; + checkpoint.bind_text(4, &source_fingerprint)?; + checkpoint.bind_i64(5, i64::from(end == total))?; + checkpoint.execute() + })?; + progress.next_ordinal = end; + progress.completed = end == total; + batches += 1; + if stop_after_batches == Some(batches) { + break; + } + } + Ok(progress) + } + + fn import_progress(&self, job_id: &str) -> Result> { + let mut statement = self.db.prepare( + "SELECT next_ordinal,total,source_fingerprint,completed + FROM import_jobs WHERE job_id=?", + )?; + statement.bind_text(1, job_id)?; + match statement.step()? { + Step::Row => Ok(Some(ImportProgress { + next_ordinal: statement.column_i64(0) as usize, + total: statement.column_i64(1) as usize, + source_fingerprint: statement.column_text(2), + completed: statement.column_i64(3) != 0, + })), + Step::Done => Ok(None), + } + } + + fn count_products(&self) -> Result<(usize, usize)> { + let mut statement = self + .db + .prepare("SELECT COUNT(*),COUNT(DISTINCT id) FROM products")?; + statement.step()?; + Ok(( + statement.column_i64(0) as usize, + statement.column_i64(1) as usize, + )) + } + + fn checkpoint(&self) -> Result<()> { + self.db.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?; + Ok(()) + } +} + +fn decode_transaction_error(error: sqlite::Error, id: &str) -> Failure { + if let Some(rest) = error.0.strip_prefix("STALE:") { + let mut numbers = rest.split(':').filter_map(|n| n.parse().ok()); + return Failure::conflict(numbers.next().unwrap_or(0), numbers.next().unwrap_or(0)); + } + if let Some(rest) = error.0.strip_prefix("INVALID:") { + let (path, message) = rest.split_once(':').unwrap_or(("specs", rest)); + return Failure::invalid(path, message); + } + if error.0.contains("not found") { + return Failure::not_found(id); + } + Failure::from(error) +} + +fn bind_optional_f64( + statement: &mut sqlite::Statement, + index: i32, + value: Option, +) -> sqlite::Result<()> { + match value { + Some(value) => statement.bind_f64(index, value), + None => statement.bind_null(index), + } +} + +fn row_product(statement: &sqlite::Statement) -> std::result::Result { + Ok(Product { + id: statement.column_text(0), + version: statement.column_i64(1), + category: statement.column_text(2), + name: statement.column_text(3), + price_cents: statement.column_i64(4), + description: statement.column_text(5), + spec_version: statement.column_i64(6), + specs: serde_json::from_str(&statement.column_text(7)).map_err(|e| e.to_string())?, + compatibility: serde_json::from_str(&statement.column_text(8)) + .map_err(|e| e.to_string())?, + tags: serde_json::from_str(&statement.column_text(9)).map_err(|e| e.to_string())?, + }) +} + +fn validate_input(input: &ProductInput, revision: i64) -> Result<()> { + if input.id.trim().is_empty() { + return Err(Failure::invalid("id", "must not be empty")); + } + let product = Product { + id: input.id.clone(), + version: 1, + category: input.category.clone(), + name: input.name.clone(), + price_cents: input.price_cents, + description: input.description.clone(), + spec_version: revision, + specs: input.specs.clone(), + compatibility: input.compatibility.clone(), + tags: input.tags.clone(), + }; + validate_product(&product) +} + +fn validate_product(product: &Product) -> Result<()> { + if product.name.trim().is_empty() { + return Err(Failure::invalid("name", "must not be empty")); + } + if product.price_cents < 0 { + return Err(Failure::invalid("price_cents", "must be non-negative")); + } + if product.tags.len() > 32 { + return Err(Failure::invalid("tags", "must contain at most 32 tags")); + } + for (index, entry) in product.compatibility.iter().enumerate() { + if entry.system.trim().is_empty() || entry.model.trim().is_empty() { + return Err(Failure::invalid( + format!("compatibility[{index}]"), + "system and model must not be empty", + )); + } + } + let specs = product + .specs + .as_object() + .ok_or_else(|| Failure::invalid("specs", "must be an object"))?; + match (product.category.as_str(), product.spec_version) { + ("laptop", 1) => { + integer_in(specs, "ram_gb", 4, 128)?; + number_in(specs, "screen_inches", 10.0, 20.0)?; + reject_unknown(specs, &["ram_gb", "screen_inches"])?; + } + ("laptop", 2) => { + integer_in(specs, "ram_gb", 4, 128)?; + number_in(specs, "screen_inches", 10.0, 20.0)?; + integer_in(specs, "battery_wh", 20, 150)?; + reject_unknown(specs, &["ram_gb", "screen_inches", "battery_wh"])?; + } + ("cable", 1) => { + number_in(specs, "length_m", 0.01, 100.0)?; + let connector = specs + .get("connector") + .and_then(Value::as_str) + .ok_or_else(|| Failure::invalid("specs.connector", "must be a string"))?; + if !["usb-c", "hdmi", "ethernet"].contains(&connector) { + return Err(Failure::invalid("specs.connector", "unsupported connector")); + } + reject_unknown(specs, &["length_m", "connector"])?; + } + ("chair", 1) => { + integer_in(specs, "max_weight_kg", 40, 300)?; + if !specs.get("adjustable").is_some_and(Value::is_boolean) { + return Err(Failure::invalid("specs.adjustable", "must be a boolean")); + } + reject_unknown(specs, &["max_weight_kg", "adjustable"])?; + } + _ => { + return Err(Failure::invalid( + "spec_version", + "unsupported schema revision", + )); + } + } + Ok(()) +} + +fn integer_in(specs: &Map, key: &str, min: i64, max: i64) -> Result<()> { + let value = specs + .get(key) + .and_then(Value::as_i64) + .ok_or_else(|| Failure::invalid(format!("specs.{key}"), "must be an integer"))?; + if !(min..=max).contains(&value) { + return Err(Failure::invalid( + format!("specs.{key}"), + format!("must be between {min} and {max}"), + )); + } + Ok(()) +} + +fn number_in(specs: &Map, key: &str, min: f64, max: f64) -> Result<()> { + let value = specs + .get(key) + .and_then(Value::as_f64) + .ok_or_else(|| Failure::invalid(format!("specs.{key}"), "must be a number"))?; + if !(min..=max).contains(&value) { + return Err(Failure::invalid( + format!("specs.{key}"), + format!("must be between {min} and {max}"), + )); + } + Ok(()) +} + +fn reject_unknown(specs: &Map, allowed: &[&str]) -> Result<()> { + if let Some(key) = specs.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(Failure::invalid( + format!("specs.{key}"), + "field is not in this schema revision", + )); + } + Ok(()) +} + +fn merge_specs(target: &mut Value, patch: Value) -> Result<()> { + let patch = patch + .as_object() + .ok_or_else(|| Failure::invalid("specs", "patch must be an object"))?; + let target = target.as_object_mut().expect("stored specs are validated"); + for (key, value) in patch { + target.insert(key.clone(), value.clone()); + } + Ok(()) +} + +#[derive(Debug, Clone, Deserialize, Default)] +struct FilterQuery { + category: Option, + exact_path: Option, + exact_value: Option, + range_path: Option, + min: Option, + max: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct DeleteQuery { + expected_version: i64, +} + +#[derive(Debug, Clone)] +struct ImportProgress { + next_ordinal: usize, + total: usize, + source_fingerprint: String, + completed: bool, +} + +type Shared = Arc>; + +fn app(catalog: Catalog) -> Router { + Router::new() + .route("/products", post(http_create).get(http_filter)) + .route( + "/products/{id}", + get(http_get).patch(http_patch).delete(http_delete), + ) + .with_state(Arc::new(Mutex::new(catalog))) +} + +async fn http_create( + State(state): State, + Json(input): Json, +) -> std::result::Result<(StatusCode, Json), HttpError> { + let product = state + .lock() + .unwrap() + .create(input) + .map_err(HttpError::from)?; + Ok((StatusCode::CREATED, Json(product))) +} + +async fn http_get( + State(state): State, + AxumPath(id): AxumPath, +) -> std::result::Result, HttpError> { + state + .lock() + .unwrap() + .get(&id) + .map(Json) + .map_err(HttpError::from) +} + +async fn http_patch( + State(state): State, + AxumPath(id): AxumPath, + Json(patch): Json, +) -> std::result::Result, HttpError> { + state + .lock() + .unwrap() + .patch(&id, patch) + .map(Json) + .map_err(HttpError::from) +} + +async fn http_delete( + State(state): State, + AxumPath(id): AxumPath, + Query(query): Query, +) -> std::result::Result, HttpError> { + state + .lock() + .unwrap() + .delete(&id, query.expected_version) + .map(Json) + .map_err(HttpError::from) +} + +async fn http_filter( + State(state): State, + Query(query): Query, +) -> std::result::Result>, HttpError> { + state + .lock() + .unwrap() + .filter(&query) + .map(Json) + .map_err(HttpError::from) +} + +fn generated_product(ordinal: usize, payload_bytes: usize) -> Product { + let category = match ordinal % 3 { + 0 => "laptop", + 1 => "cable", + _ => "chair", + }; + let ram_gb = [8, 16, 32, 64][(ordinal / 3) % 4]; + let connector = ["usb-c", "hdmi", "ethernet"][(ordinal / 3) % 3]; + let specs = match category { + "laptop" => json!({ + "ram_gb": ram_gb, + "screen_inches": 13.0 + (ordinal % 4) as f64, + "battery_wh": 40 + (ordinal % 80) as i64 + }), + "cable" => json!({ + "length_m": 0.5 + (ordinal % 20) as f64 * 0.5, + "connector": connector + }), + _ => json!({ + "max_weight_kg": 80 + (ordinal % 140) as i64, + "adjustable": ordinal.is_multiple_of(2) + }), + }; + Product { + id: format!("bulk-{ordinal:06}"), + version: 1, + category: category.into(), + name: format!("Seed product {ordinal}"), + price_cents: 1_000 + (ordinal % 100_000) as i64, + description: seeded_text(ordinal as u64, payload_bytes), + spec_version: if category == "laptop" { 2 } else { 1 }, + specs, + compatibility: vec![Compatibility { + system: "catalog".into(), + model: format!("m-{}", ordinal % 50), + }], + tags: vec![format!("tag-{}", ordinal % 20)], + } +} + +fn seeded_text(mut state: u64, bytes: usize) -> String { + let mut output = String::with_capacity(bytes); + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + while output.len() < bytes { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + output.push(ALPHABET[(state as usize) % ALPHABET.len()] as char); + } + output +} + +fn reference_filter(products: &[Product], query: &FilterQuery) -> Vec { + let mut ids = products + .iter() + .filter(|product| { + query + .category + .as_ref() + .is_none_or(|category| product.category == *category) + }) + .filter(|product| { + query.exact_path.as_ref().is_none_or(|path| { + let value = query.exact_value.as_deref().unwrap_or(""); + json_path(&product.specs, path).is_some_and(|candidate| match candidate { + Value::String(text) => text == value, + Value::Number(number) => value + .parse::() + .ok() + .is_some_and(|v| number.as_f64() == Some(v)), + Value::Bool(flag) => value == flag.to_string(), + _ => false, + }) + }) + }) + .filter(|product| { + query.range_path.as_ref().is_none_or(|path| { + json_path(&product.specs, path) + .and_then(Value::as_f64) + .is_some_and(|value| { + query.min.is_none_or(|min| value >= min) + && query.max.is_none_or(|max| value <= max) + }) + }) + }) + .map(|product| product.id.clone()) + .collect::>(); + ids.sort(); + ids +} + +fn json_path<'a>(specs: &'a Value, path: &str) -> Option<&'a Value> { + let key = path.strip_prefix("specs.")?; + specs.get(key) +} + +fn assert_reference(catalog: &Catalog, query: FilterQuery) -> Result { + let expected = reference_filter(&catalog.all()?, &query); + let actual = catalog + .filter(&query)? + .into_iter() + .map(|product| product.id) + .collect::>(); + if actual != expected { + return Err(Failure::internal(format!( + "filter mismatch: actual={actual:?}, expected={expected:?}" + ))); + } + Ok(actual.len()) +} + +fn example_input(id: &str, category: &str) -> ProductInput { + let specs = match category { + "laptop" => json!({"ram_gb":16,"screen_inches":14.0,"battery_wh":70}), + "cable" => json!({"length_m":2.0,"connector":"usb-c"}), + _ => json!({"max_weight_kg":120,"adjustable":true}), + }; + ProductInput { + id: id.into(), + category: category.into(), + name: format!("Example {category}"), + price_cents: 12_500, + description: "representative product".into(), + specs, + compatibility: vec![Compatibility { + system: "inventory".into(), + model: "v1".into(), + }], + tags: vec!["wholesale".into()], + } +} + +fn fresh_path(name: &str) -> PathBuf { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("target/trial-data"); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join(name); + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{}", path.display(), suffix)); + } + path +} + +fn run_demo() -> Result<()> { + let path = fresh_path("demo.sqlite"); + { + let legacy = Connection::open(&path)?; + legacy.execute_batch("CREATE TABLE legacy_orders(id INTEGER PRIMARY KEY); INSERT INTO legacy_orders VALUES(7);")?; + } + let catalog = Catalog::open(&path)?; + catalog.install_demo_schemas(1)?; + let mut old = example_input("legacy-laptop", "laptop"); + old.specs = json!({"ram_gb":16,"screen_inches":14.0}); + catalog.create_at_revision(old, 1)?; + catalog.activate_schema("laptop", 2)?; + catalog.create(example_input("cable-1", "cable"))?; + catalog.create(example_input("chair-1", "chair"))?; + let before = catalog.get("legacy-laptop")?; + let migrated = catalog.migrate_laptop_v1_to_v2("legacy-laptop", 60)?; + let patched = catalog.patch( + "cable-1", + ProductPatch { + expected_version: 1, + specs: Some(json!({"length_m":3.5})), + ..ProductPatch::default() + }, + )?; + let stale_rejected = catalog + .patch( + "cable-1", + ProductPatch { + expected_version: 1, + name: Some("stale".into()), + ..ProductPatch::default() + }, + ) + .is_err(); + let exact = assert_reference( + &catalog, + FilterQuery { + exact_path: Some("specs.connector".into()), + exact_value: Some("usb-c".into()), + ..FilterQuery::default() + }, + )?; + let range = assert_reference( + &catalog, + FilterQuery { + range_path: Some("specs.length_m".into()), + min: Some(3.0), + max: Some(4.0), + ..FilterQuery::default() + }, + )?; + println!("demo database: {}", path.display()); + println!("legacy SQLite table preserved: yes"); + println!( + "v1 readable after v2 activation: spec_version={}", + before.spec_version + ); + println!( + "tested migration: spec_version={}, version={}", + migrated.spec_version, migrated.version + ); + println!( + "partial patch preserved connector={}, new version={}", + patched.specs["connector"], patched.version + ); + println!("stale conditional patch rejected: {stale_rejected}"); + println!("independent filter evaluator: exact={exact}, range={range}, all matched"); + println!("Fold decision: no-fit for authoritative catalog path; SQLite retained"); + Ok(()) +} + +fn run_import_check(total: usize, payload_bytes: usize) -> Result<()> { + let path = fresh_path(&format!("import-{total}-{payload_bytes}.sqlite")); + let start = Instant::now(); + { + let catalog = Catalog::open(&path)?; + catalog.install_demo_schemas(2)?; + let interrupted = catalog.resume_import("seed-v1", total, 1_000, Some(7), payload_bytes)?; + println!( + "simulated interruption checkpoint: {}/{}", + interrupted.next_ordinal, total + ); + } + let catalog = Catalog::open(&path)?; + catalog.install_demo_schemas(2)?; + let resumed = catalog.resume_import("seed-v1", total, 1_000, None, payload_bytes)?; + let (count, distinct) = catalog.count_products()?; + catalog.checkpoint()?; + let bytes = std::fs::metadata(&path).map_err(Failure::internal)?.len(); + println!("resume completed: {}", resumed.completed); + println!("rows: {count}; distinct ids: {distinct}; expected: {total}"); + println!("record payload: {payload_bytes} bytes; database: {bytes} bytes"); + println!("elapsed: {:.3}s", start.elapsed().as_secs_f64()); + if count != total || distinct != total || !resumed.completed { + return Err(Failure::internal("import integrity check failed")); + } + Ok(()) +} + +fn run_storage_check(total: usize, payload_bytes: usize) -> Result<()> { + let indexed_path = fresh_path(&format!("storage-indexed-{total}-{payload_bytes}.sqlite")); + let baseline_path = fresh_path(&format!("storage-baseline-{total}-{payload_bytes}.sqlite")); + let catalog = Catalog::open(&indexed_path)?; + catalog.install_demo_schemas(2)?; + catalog.resume_import("storage", total, 1_000, None, payload_bytes)?; + catalog.checkpoint()?; + + let baseline = Connection::open(&baseline_path)?; + baseline.execute_batch( + "CREATE TABLE products ( + id TEXT PRIMARY KEY, version INTEGER NOT NULL, category TEXT NOT NULL, + name TEXT NOT NULL, price_cents INTEGER NOT NULL, description TEXT NOT NULL, + spec_version INTEGER NOT NULL, specs_json TEXT NOT NULL, + compatibility_json TEXT NOT NULL, tags_json TEXT NOT NULL + ); + CREATE INDEX products_category ON products(category); + CREATE INDEX products_price ON products(price_cents);", + )?; + baseline.transaction(|| { + let mut insert = baseline.prepare( + "INSERT INTO products + (id,version,category,name,price_cents,description,spec_version,specs_json,compatibility_json,tags_json) + VALUES(?,?,?,?,?,?,?,?,?,?)", + )?; + for ordinal in 0..total { + let product = generated_product(ordinal, payload_bytes); + insert.bind_text(1, &product.id)?; + insert.bind_i64(2, product.version)?; + insert.bind_text(3, &product.category)?; + insert.bind_text(4, &product.name)?; + insert.bind_i64(5, product.price_cents)?; + insert.bind_text(6, &product.description)?; + insert.bind_i64(7, product.spec_version)?; + insert.bind_text(8, &serde_json::to_string(&product.specs).unwrap())?; + insert.bind_text(9, &serde_json::to_string(&product.compatibility).unwrap())?; + insert.bind_text(10, &serde_json::to_string(&product.tags).unwrap())?; + insert.execute()?; + insert.reset()?; + } + Ok(()) + })?; + drop(baseline); + let indexed_bytes = std::fs::metadata(&indexed_path) + .map_err(Failure::internal)? + .len(); + let baseline_bytes = std::fs::metadata(&baseline_path) + .map_err(Failure::internal)? + .len(); + let ratio = indexed_bytes as f64 / baseline_bytes as f64; + println!("storage sample: {total} records at {payload_bytes} payload bytes"); + println!("baseline SQLite: {baseline_bytes} bytes"); + println!("indexed SQLite: {indexed_bytes} bytes"); + println!("ratio: {ratio:.3}x; target: <1.5x"); + if ratio >= 1.5 { + return Err(Failure::internal("storage ratio exceeded 1.5x")); + } + Ok(()) +} + +async fn run_burst(count: usize) -> Result<()> { + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + let path = fresh_path(&format!("burst-{count}.sqlite")); + let catalog = Catalog::open(&path)?; + catalog.install_demo_schemas(2)?; + catalog.resume_import("burst-seed", count, 1_000, None, 2_048)?; + let router = app(catalog); + let mut tasks = Vec::new(); + for index in 0..150 { + let service = router.clone(); + tasks.push(tokio::spawn(async move { + let start = Instant::now(); + let request = if index < 120 { + Request::builder() + .uri(format!("/products/bulk-{:06}", index % count)) + .body(Body::empty()) + .unwrap() + } else { + Request::builder() + .method("PATCH") + .header("content-type", "application/json") + .uri(format!("/products/bulk-{:06}", index % count)) + .body(Body::from(format!( + r#"{{"expected_version":1,"price_cents":{}}}"#, + 20_000 + index + ))) + .unwrap() + }; + let status = service.oneshot(request).await.unwrap().status(); + (index < 120, status, start.elapsed()) + })); + } + let mut reads = Vec::new(); + let mut writes = Vec::new(); + for task in tasks { + let (is_read, status, duration) = task.await.map_err(Failure::internal)?; + if !status.is_success() { + return Err(Failure::internal(format!("burst request failed: {status}"))); + } + if is_read { + reads.push(duration); + } else { + writes.push(duration); + } + } + reads.sort(); + writes.sort(); + let read_p95 = percentile(&reads, 95); + let write_p95 = percentile(&writes, 95); + println!("simultaneous burst: 150 requests (120 reads / 30 writes)"); + println!("seed: {count} representative 2 KiB records"); + println!("read p95: {:.3} ms", read_p95.as_secs_f64() * 1_000.0); + println!("write p95: {:.3} ms", write_p95.as_secs_f64() * 1_000.0); + println!("targets: reads <50 ms; writes <100 ms"); + Ok(()) +} + +fn percentile(values: &[Duration], percentile: usize) -> Duration { + values[((values.len() - 1) * percentile).div_ceil(100)] +} + +async fn serve(path: PathBuf) -> Result<()> { + let catalog = Catalog::open(&path)?; + catalog.install_demo_schemas(2)?; + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") + .await + .map_err(Failure::internal)?; + println!("catalog API listening at http://127.0.0.1:3000"); + axum::serve(listener, app(catalog)) + .await + .map_err(Failure::internal) +} + +#[tokio::main] +async fn main() { + let args = std::env::args().skip(1).collect::>(); + let result = match args.first().map(String::as_str) { + None | Some("demo") => run_demo(), + Some("import-check") => { + let total = args.get(1).and_then(|v| v.parse().ok()).unwrap_or(25_000); + let bytes = args.get(2).and_then(|v| v.parse().ok()).unwrap_or(2_048); + run_import_check(total, bytes) + } + Some("burst") => { + let count = args.get(1).and_then(|v| v.parse().ok()).unwrap_or(25_000); + run_burst(count).await + } + Some("storage-check") => { + let total = args.get(1).and_then(|v| v.parse().ok()).unwrap_or(25_000); + let bytes = args.get(2).and_then(|v| v.parse().ok()).unwrap_or(2_048); + run_storage_check(total, bytes) + } + Some("serve") => { + let path = args + .get(1) + .map(PathBuf::from) + .unwrap_or_else(|| fresh_path("server.sqlite")); + serve(path).await + } + Some(command) => Err(Failure::invalid( + "command", + format!( + "unknown command {command}; use demo, import-check, storage-check, burst, or serve" + ), + )), + }; + if let Err(error) = result { + eprintln!("{}", serde_json::to_string(&error.body).unwrap()); + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use http_body_util::BodyExt; + use std::collections::BTreeSet; + use tower::ServiceExt; + + fn catalog(name: &str) -> Catalog { + let catalog = Catalog::open(&fresh_path(name)).unwrap(); + catalog.install_demo_schemas(2).unwrap(); + catalog + } + + #[test] + fn validation_has_stable_nested_path() { + let catalog = catalog("validation.sqlite"); + let mut input = example_input("bad", "laptop"); + input.specs["battery_wh"] = json!(5); + let error = catalog.create(input).unwrap_err(); + assert_eq!(error.body.path.as_deref(), Some("specs.battery_wh")); + } + + #[test] + fn partial_patch_preserves_omitted_fields_and_rejects_stale_version() { + let catalog = catalog("patch.sqlite"); + let original = catalog.create(example_input("c-1", "cable")).unwrap(); + let updated = catalog + .patch( + "c-1", + ProductPatch { + expected_version: original.version, + specs: Some(json!({"length_m":4.0})), + ..ProductPatch::default() + }, + ) + .unwrap(); + assert_eq!(updated.specs["connector"], "usb-c"); + assert_eq!(updated.name, original.name); + let stale = catalog + .patch( + "c-1", + ProductPatch { + expected_version: 1, + name: Some("lost update".into()), + ..ProductPatch::default() + }, + ) + .unwrap_err(); + assert_eq!(stale.status, StatusCode::CONFLICT); + } + + #[test] + fn old_revision_is_readable_and_migration_is_tested() { + let path = fresh_path("migration.sqlite"); + let catalog = Catalog::open(&path).unwrap(); + catalog.install_demo_schemas(1).unwrap(); + let mut input = example_input("old", "laptop"); + input.specs = json!({"ram_gb":16,"screen_inches":14.0}); + catalog.create_at_revision(input, 1).unwrap(); + catalog.activate_schema("laptop", 2).unwrap(); + assert_eq!(catalog.get("old").unwrap().spec_version, 1); + let migrated = catalog.migrate_laptop_v1_to_v2("old", 55).unwrap(); + assert_eq!(migrated.spec_version, 2); + assert_eq!(migrated.specs["battery_wh"], 55); + } + + #[test] + fn exact_and_range_filters_match_independent_evaluator() { + let catalog = catalog("filters.sqlite"); + catalog + .resume_import("filters", 300, 100, None, 128) + .unwrap(); + for query in [ + FilterQuery { + exact_path: Some("specs.connector".into()), + exact_value: Some("hdmi".into()), + ..FilterQuery::default() + }, + FilterQuery { + category: Some("laptop".into()), + range_path: Some("specs.ram_gb".into()), + min: Some(16.0), + max: Some(32.0), + ..FilterQuery::default() + }, + FilterQuery { + range_path: Some("specs.max_weight_kg".into()), + min: Some(100.0), + max: Some(180.0), + ..FilterQuery::default() + }, + ] { + assert_reference(&catalog, query).unwrap(); + } + } + + #[test] + fn interrupted_import_resumes_without_gaps_or_duplicates() { + let path = fresh_path("resume.sqlite"); + { + let catalog = Catalog::open(&path).unwrap(); + catalog.install_demo_schemas(2).unwrap(); + let progress = catalog + .resume_import("job", 2_503, 100, Some(4), 128) + .unwrap(); + assert_eq!(progress.next_ordinal, 400); + } + let catalog = Catalog::open(&path).unwrap(); + catalog.install_demo_schemas(2).unwrap(); + assert!( + catalog + .resume_import("job", 2_503, 100, None, 128) + .unwrap() + .completed + ); + assert_eq!(catalog.count_products().unwrap(), (2_503, 2_503)); + let ids = catalog + .all() + .unwrap() + .into_iter() + .map(|p| p.id) + .collect::>(); + assert!((0..2_503).all(|i| ids.contains(&format!("bulk-{i:06}")))); + } + + #[test] + fn import_resume_rejects_changed_source() { + let path = fresh_path("resume-source-mismatch.sqlite"); + { + let catalog = Catalog::open(&path).unwrap(); + catalog.install_demo_schemas(2).unwrap(); + catalog.resume_import("job", 250, 100, Some(1), 8).unwrap(); + } + let catalog = Catalog::open(&path).unwrap(); + catalog.install_demo_schemas(2).unwrap(); + let error = catalog + .resume_import("job", 250, 100, None, 64) + .unwrap_err(); + assert_eq!(error.body.path.as_deref(), Some("source_fingerprint")); + assert_eq!(catalog.count_products().unwrap(), (100, 100)); + } + + #[test] + fn import_rejects_conflicting_preexisting_product() { + let catalog = catalog("resume-conflict.sqlite"); + catalog + .create(example_input("bulk-000000", "laptop")) + .unwrap(); + + let error = catalog.resume_import("job", 10, 10, None, 64).unwrap_err(); + assert!(error.body.error.contains("conflicts with existing content")); + assert!(catalog.import_progress("job").unwrap().is_none()); + assert_eq!(catalog.count_products().unwrap(), (1, 1)); + assert_eq!(catalog.get("bulk-000000").unwrap().name, "Example laptop"); + } + + #[tokio::test] + async fn http_crud_keeps_product_response_shape() { + let router = app(catalog("http.sqlite")); + let input = example_input("api-1", "chair"); + let create = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/products") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&input).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(create.status(), StatusCode::CREATED); + let created: Product = + serde_json::from_slice(&create.into_body().collect().await.unwrap().to_bytes()) + .unwrap(); + let get = router + .oneshot( + Request::builder() + .uri("/products/api-1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let fetched: Product = + serde_json::from_slice(&get.into_body().collect().await.unwrap().to_bytes()).unwrap(); + assert_eq!(fetched, created); + } + + #[test] + fn opening_catalog_preserves_unrelated_legacy_table() { + let path = fresh_path("legacy.sqlite"); + let legacy = Connection::open(&path).unwrap(); + legacy + .execute_batch( + "CREATE TABLE orders(id INTEGER PRIMARY KEY); INSERT INTO orders VALUES(9);", + ) + .unwrap(); + drop(legacy); + let catalog = Catalog::open(&path).unwrap(); + let mut statement = catalog.db.prepare("SELECT id FROM orders").unwrap(); + assert!(matches!(statement.step().unwrap(), Step::Row)); + assert_eq!(statement.column_i64(0), 9); + } +} diff --git a/developer-simulation/runs/2026-07-31--parts-catalog-evolution/src/sqlite.rs b/developer-simulation/runs/2026-07-31--parts-catalog-evolution/src/sqlite.rs new file mode 100644 index 0000000..edbb448 --- /dev/null +++ b/developer-simulation/runs/2026-07-31--parts-catalog-evolution/src/sqlite.rs @@ -0,0 +1,290 @@ +//! Minimal, parameterized SQLite wrapper for the prototype. +//! +//! This avoids adding a database dependency that was not available in the +//! sanitized checkout while still exercising the real system SQLite library. + +use std::ffi::{CStr, CString, c_char, c_int, c_void}; +use std::fmt::{Display, Formatter}; +use std::path::Path; +use std::ptr::{null, null_mut}; + +#[allow(non_camel_case_types)] +enum sqlite3 {} +#[allow(non_camel_case_types)] +enum sqlite3_stmt {} + +const SQLITE_OK: c_int = 0; +const SQLITE_ROW: c_int = 100; +const SQLITE_DONE: c_int = 101; +const SQLITE_OPEN_READWRITE: c_int = 0x0000_0002; +const SQLITE_OPEN_CREATE: c_int = 0x0000_0004; +const SQLITE_OPEN_FULLMUTEX: c_int = 0x0001_0000; + +#[link(name = "sqlite3")] +unsafe extern "C" { + fn sqlite3_open_v2( + filename: *const c_char, + pp_db: *mut *mut sqlite3, + flags: c_int, + z_vfs: *const c_char, + ) -> c_int; + fn sqlite3_close_v2(db: *mut sqlite3) -> c_int; + fn sqlite3_errmsg(db: *mut sqlite3) -> *const c_char; + fn sqlite3_exec( + db: *mut sqlite3, + sql: *const c_char, + callback: Option c_int>, + arg: *mut c_void, + errmsg: *mut *mut c_char, + ) -> c_int; + fn sqlite3_prepare_v2( + db: *mut sqlite3, + sql: *const c_char, + n_byte: c_int, + statement: *mut *mut sqlite3_stmt, + tail: *mut *const c_char, + ) -> c_int; + fn sqlite3_finalize(statement: *mut sqlite3_stmt) -> c_int; + fn sqlite3_step(statement: *mut sqlite3_stmt) -> c_int; + fn sqlite3_reset(statement: *mut sqlite3_stmt) -> c_int; + fn sqlite3_clear_bindings(statement: *mut sqlite3_stmt) -> c_int; + fn sqlite3_bind_int64(statement: *mut sqlite3_stmt, index: c_int, value: i64) -> c_int; + fn sqlite3_bind_double(statement: *mut sqlite3_stmt, index: c_int, value: f64) -> c_int; + fn sqlite3_bind_text( + statement: *mut sqlite3_stmt, + index: c_int, + value: *const c_char, + length: c_int, + destructor: unsafe extern "C" fn(*mut c_void), + ) -> c_int; + fn sqlite3_bind_null(statement: *mut sqlite3_stmt, index: c_int) -> c_int; + fn sqlite3_column_int64(statement: *mut sqlite3_stmt, column: c_int) -> i64; + fn sqlite3_column_text(statement: *mut sqlite3_stmt, column: c_int) -> *const u8; + fn sqlite3_column_bytes(statement: *mut sqlite3_stmt, column: c_int) -> c_int; + fn sqlite3_changes64(db: *mut sqlite3) -> i64; +} + +unsafe extern "C" fn transient_destructor(_: *mut c_void) {} + +#[derive(Debug, Clone)] +pub struct Error(pub String); + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Error {} + +pub type Result = std::result::Result; + +pub struct Connection { + raw: *mut sqlite3, +} + +// The connection is opened in SQLite's full-mutex mode and the application +// additionally places it behind a Rust Mutex before sharing it. +unsafe impl Send for Connection {} + +impl Connection { + pub fn open(path: &Path) -> Result { + let filename = CString::new(path.to_string_lossy().as_bytes()) + .map_err(|_| Error("database path contains a NUL byte".into()))?; + let mut raw = null_mut(); + // SAFETY: filename is NUL-terminated, raw is a valid out pointer, and + // the returned handle is owned by Connection. + let rc = unsafe { + sqlite3_open_v2( + filename.as_ptr(), + &mut raw, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, + null(), + ) + }; + if rc != SQLITE_OK { + let message = if raw.is_null() { + format!("sqlite open failed with code {rc}") + } else { + error_message(raw) + }; + if !raw.is_null() { + // SAFETY: raw came from sqlite3_open_v2 and is not retained. + unsafe { sqlite3_close_v2(raw) }; + } + return Err(Error(message)); + } + Ok(Self { raw }) + } + + pub fn execute_batch(&self, sql: &str) -> Result<()> { + let sql = CString::new(sql).map_err(|_| Error("SQL contains a NUL byte".into()))?; + // SAFETY: self.raw is an open handle and sql is NUL-terminated. + let rc = unsafe { sqlite3_exec(self.raw, sql.as_ptr(), None, null_mut(), null_mut()) }; + self.check(rc) + } + + pub fn prepare(&self, sql: &str) -> Result { + let sql = CString::new(sql).map_err(|_| Error("SQL contains a NUL byte".into()))?; + let mut raw = null_mut(); + // SAFETY: pointers are valid for this call; SQLite owns the compiled + // statement until Statement::drop finalizes it. + let rc = unsafe { sqlite3_prepare_v2(self.raw, sql.as_ptr(), -1, &mut raw, null_mut()) }; + self.check(rc)?; + Ok(Statement { raw, db: self.raw }) + } + + pub fn changes(&self) -> i64 { + // SAFETY: self.raw is an open handle. + unsafe { sqlite3_changes64(self.raw) } + } + + pub fn transaction(&self, operation: impl FnOnce() -> Result) -> Result { + self.execute_batch("BEGIN IMMEDIATE")?; + match operation() { + Ok(value) => { + self.execute_batch("COMMIT")?; + Ok(value) + } + Err(error) => { + let _ = self.execute_batch("ROLLBACK"); + Err(error) + } + } + } + + fn check(&self, rc: c_int) -> Result<()> { + if rc == SQLITE_OK { + Ok(()) + } else { + Err(Error(error_message(self.raw))) + } + } +} + +impl Drop for Connection { + fn drop(&mut self) { + // SAFETY: raw is uniquely owned and not used after drop. + unsafe { sqlite3_close_v2(self.raw) }; + } +} + +pub enum Step { + Row, + Done, +} + +pub struct Statement { + raw: *mut sqlite3_stmt, + db: *mut sqlite3, +} + +impl Statement { + pub fn bind_text(&mut self, index: i32, value: &str) -> Result<()> { + let bytes = value.as_bytes(); + let length = + i32::try_from(bytes.len()).map_err(|_| Error("bound text too large".into()))?; + // SAFETY: bytes remains valid for the call and SQLITE_TRANSIENT is + // represented by the special destructor value documented by SQLite. + let rc = unsafe { + sqlite3_bind_text( + self.raw, + index, + bytes.as_ptr().cast(), + length, + std::mem::transmute::<*const c_void, unsafe extern "C" fn(*mut c_void)>( + (-1_isize) as *const c_void, + ), + ) + }; + self.check(rc) + } + + pub fn bind_i64(&mut self, index: i32, value: i64) -> Result<()> { + // SAFETY: raw is a valid prepared statement. + self.check(unsafe { sqlite3_bind_int64(self.raw, index, value) }) + } + + pub fn bind_f64(&mut self, index: i32, value: f64) -> Result<()> { + // SAFETY: raw is a valid prepared statement. + self.check(unsafe { sqlite3_bind_double(self.raw, index, value) }) + } + + #[allow(dead_code)] + pub fn bind_null(&mut self, index: i32) -> Result<()> { + // SAFETY: raw is a valid prepared statement. + self.check(unsafe { sqlite3_bind_null(self.raw, index) }) + } + + pub fn step(&mut self) -> Result { + // SAFETY: raw is a valid prepared statement. + match unsafe { sqlite3_step(self.raw) } { + SQLITE_ROW => Ok(Step::Row), + SQLITE_DONE => Ok(Step::Done), + _ => Err(Error(error_message(self.db))), + } + } + + pub fn execute(&mut self) -> Result<()> { + match self.step()? { + Step::Done => Ok(()), + Step::Row => Err(Error("statement unexpectedly returned a row".into())), + } + } + + #[allow(dead_code)] + pub fn reset(&mut self) -> Result<()> { + // SAFETY: raw is a valid prepared statement. + self.check(unsafe { sqlite3_reset(self.raw) })?; + // SAFETY: raw is a valid prepared statement. + self.check(unsafe { sqlite3_clear_bindings(self.raw) }) + } + + pub fn column_i64(&self, column: i32) -> i64 { + // SAFETY: caller only reads columns while positioned on a row. + unsafe { sqlite3_column_int64(self.raw, column) } + } + + pub fn column_text(&self, column: i32) -> String { + // SAFETY: caller only reads columns while positioned on a row; SQLite + // owns this buffer until the statement advances or is finalized. + unsafe { + let pointer = sqlite3_column_text(self.raw, column); + if pointer.is_null() { + return String::new(); + } + let length = sqlite3_column_bytes(self.raw, column) as usize; + String::from_utf8_lossy(std::slice::from_raw_parts(pointer, length)).into_owned() + } + } + + fn check(&self, rc: c_int) -> Result<()> { + if rc == SQLITE_OK { + Ok(()) + } else { + Err(Error(error_message(self.db))) + } + } +} + +impl Drop for Statement { + fn drop(&mut self) { + // SAFETY: raw is uniquely owned and no longer used after drop. + unsafe { sqlite3_finalize(self.raw) }; + } +} + +fn error_message(db: *mut sqlite3) -> String { + // SAFETY: db is an open SQLite handle and sqlite3_errmsg returns a stable + // NUL-terminated string owned by SQLite. + unsafe { + CStr::from_ptr(sqlite3_errmsg(db)) + .to_string_lossy() + .into_owned() + } +} + +#[allow(dead_code)] +fn _keep_transient_symbol_referenced() { + let _ = transient_destructor as unsafe extern "C" fn(*mut c_void); +} diff --git a/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/BASELINE.md b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/BASELINE.md new file mode 100644 index 0000000..32137e5 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/BASELINE.md @@ -0,0 +1,32 @@ +# Concrete baseline + +The baseline is a small Python preflight that opens a ZIP, requires an exact +`manifest.json` member name, and rejects archive members whose filename does +not end in `.json`, `.nc`, or `.gcode` (case-insensitive). It does not parse +the manifest, read member contents, calculate checksums, compare declared byte +counts, check tools, reject unsafe paths, detect case-colliding names, enforce +a bundle-size policy, or stage files. + +Expected baseline classifications before implementation: + +| Fixture | Baseline expectation | Required classification | +| --- | --- | --- | +| valid | ready | ready | +| truncated member | ready | invalid | +| checksum mismatch | ready | invalid | +| undeclared file with allowed extension | ready | invalid | +| missing declared file | ready | invalid | +| duplicate path differing only by case | ready | invalid | +| absolute path | ready | invalid | +| parent traversal | ready | invalid | +| oversized bundle | ready | invalid | +| undeclared disallowed extension | invalid | invalid | + +This is deliberately concrete and runnable, but it represents only the stated +existing checks. It is not presented as a safe implementation. + +Measured correction: Python 3.14.6's `zipfile.ZipFile` rejected the physically +truncated fixture while opening it, so the measured baseline result for that +row was invalid rather than the predicted ready. It marked valid and every +other adversarial fixture ready. The prediction is retained above to make the +discovery trail explicit rather than retroactively changing the baseline. diff --git a/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/Cargo.toml b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/Cargo.toml new file mode 100644 index 0000000..be036e0 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cnc-job-bundle-preflight" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[lints.clippy] +all = "deny" +pedantic = "deny" diff --git a/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/EVIDENCE.md b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/EVIDENCE.md new file mode 100644 index 0000000..7fbca21 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/EVIDENCE.md @@ -0,0 +1,310 @@ +# Trial report: CNC job bundle preflight + +Run completed: 2026-08-01 06:22 EDT + +## Outcome + +The result is a useful, runnable local prototype and a **no-fit conclusion for +BogKit**. The prototype correctly classified the required valid, truncated, +checksum-mismatched, undeclared, missing, case-colliding, absolute-path, +parent-traversal, and oversized fixtures within its documented ZIP subset. It +also rejected a missing tool, reported a multi-error manifest in stable order, +accepted exactly 1,000 declared files, staged only the valid bundle, and failed +closed when the selected staging root was a symlink. After skeptical review, +staging also uses a temporary directory, removes incomplete output after an +ordinary write failure, rechecks every copied member, and assigns the `ready` +name only after the whole copy succeeds. + +The 2 GiB sparse member was actually read and hashed, not skipped from header +metadata. The final archived-workspace timed run reported 1,998,848 bytes +maximum resident set (about 1.91 MiB), below the 32 MiB acceptance limit, and +2,147,483,919 streamed bytes including the manifest. The fixture was rejected +only by the prototype's 1 GiB total-member policy and was never staged. + +This is not production-ready. It supports only classic stored ZIPs, its 1 GiB +policy needs a product decision, and staging is not hardened against an active +local process racing path checks. It intentionally does not interpret or run +G-code. + +## Brief used + +The simulated developer maintains an on-premises machine controller and has +intermediate Rust experience. The existing Python preflight checks required +filenames and extensions in operator ZIP bundles. The recurring failures are +truncation, checksums, undeclared or missing files, case-colliding names, +unsafe paths, and missing tools. Bundles are untrusted, contain 1-1,000 files, +are usually 10 MB and occasionally 2 GB, must be checked offline by streaming, +must stay below 32 MB working memory, and must never write outside selected +staging. G-code semantics, malware detection, repair, authentication, and +production deployment are out of scope. + +## Ordered discovery, friction, and debugging trail + +1. I listed only the public root README and public examples. I then read the + root README plus each public example manifest and main source. I did not + inspect another checkout, a prior trial, the internet, or simulation state. +2. Before choosing any BogKit component, I froze a runnable Python baseline in + `baseline.py` and its predicted comparison in `BASELINE.md`: exact + `manifest.json` presence plus case-insensitive `.json`, `.nc`, or `.gcode` + filename checks, with no extraction or content reads. +3. Only after freezing that baseline, I inspected the root Cargo workspace and + local tool versions. The public examples showed Fold for persistent, + incrementally maintained views, ESE for embeddings, and ANNy for nearest + neighbors. None addresses archive parsing, trust-boundary path handling, or + streaming checksums, so I chose no BogKit component. +4. I created an isolated nested Rust workspace under `trial-output` and made no + change to the root workspace or BogKit core. The CLI uses only Serde and + Serde JSON; the classic ZIP reader/writer, SHA-256, and CRC32 are local. +5. The initial formatter check failed with formatting diffs. After formatting, + all three unit tests passed, but strict Clippy rejected ten issues. I moved + the 64 KiB stream buffer from the stack to bounded heap storage, fixed style + findings, and narrowly allowed length/name lints in the ZIP parser, fixture + generator, manifest validator, and standard SHA-256 round variables. +6. The first fixture-generation run failed with `File exists (os error 17)` + because the generator required the selected output directory not to exist. + I changed it to accept an existing directory, matching safe temporary-folder + practice, and regenerated successfully. +7. The normal demonstration produced the expected ten classifications. Only + valid staged. The staged program hash was independently checked. +8. The measured Python baseline corrected one prediction: Python 3.14.6 + rejected the physically truncated ZIP on open. It still marked checksum + mismatch, undeclared, missing, duplicate-case, absolute-path, + parent-traversal, missing-tool, and multi-error bundles ready. +9. A 2 GiB logical sparse ZIP (20 KiB physical allocation) was generated. The + first sandboxed `/usr/bin/time -l` run completed the scan in 17.09 seconds + but could not query memory (`sysctl kern.clockrate: Operation not + permitted`). A `ps` sampler was also blocked and correctly discarded as + evidence. The approved read-only rerun reported full process accounting. +10. Two multi-error runs had identical whole-output hashes. The 1,000-file + fixture passed. A valid bundle aimed at a symlinked staging root became + invalid with `staging_failed`, and the symlink target stayed empty. +11. Skeptical review found that a late ordinary write failure could leave an + incomplete directory named `ready`, and that the second member read used + for staging was not rechecked. The coordinator changed staging to use a + temporary directory, remove incomplete output on failure, recheck byte + count, CRC, and SHA-256 for every copy, and rename only after completion. + Three regressions cover file/parent path collisions, incomplete-output + cleanup, and content rechecking. Entry count and member-name length are now + rejected before metadata allocation can grow beyond the trial policy. + +## Baseline comparison + +| Fixture | Python baseline measured | Rust prototype measured | +| --- | --- | --- | +| valid | ready | ready, staged | +| truncated | invalid (`zipfile` open failure) | invalid (`archive_invalid`) | +| checksum mismatch | **ready** | invalid (`checksum_mismatch`) | +| undeclared allowed-extension file | **ready** | invalid (`archive_file_undeclared`) | +| missing declared file | **ready** | invalid (`declared_file_missing`) | +| duplicate case | **ready** | invalid (archive and manifest case collision) | +| absolute path | **ready** | invalid (archive, manifest, and entry path unsafe) | +| parent traversal | **ready** | invalid (archive, manifest, and entry path unsafe) | +| missing tool | **ready** | invalid (`required_tool_missing`) | +| independent manifest errors | **ready** | invalid, 15 stable-ordered diagnostics | +| 2 GiB oversized sparse member | not run; baseline never reads members | invalid after full stream/hash | + +The baseline's useful behavior is limited to central-directory readability, +the exact manifest filename, and extensions. The smallest safe improvement is +the prototype's sequence: inventory all names first, parse a bounded manifest, +collect independent errors, stream declared content for size/hash/CRC, and +only then create staging. + +## Exact commands and observed evidence + +### Public discovery + +From `/private/tmp/bogkit-2026-08-01-trial-b.LaXksh`: + +```console +pwd && rg --files -g 'README*' -g 'examples/**' -g '!target' +sed -n '1,240p' README.md; for f in examples/*/Cargo.toml examples/*/src/main.rs; do echo "FILE $f"; sed -n '1,260p' "$f"; done +``` + +Observed: one root README and four public examples (`starter`, `timeseries`, +`chat`, `search`). The first command confirmed the assigned checkout path. + +### Build checks + +From the prototype directory: + +```console +cargo fmt --all -- --check +cargo test --offline +cargo clippy --offline --all-targets -- -D warnings +cargo build --offline --release +``` + +Observed after skeptical-review fixes: formatting passed; 6 tests passed, 0 failed; strict +Clippy passed with warnings denied; release build passed. Rust was +`rustc 1.95.0` and Cargo was `1.95.0`. + +### Fixture generation and normal demonstration + +```console +target/release/cnc-job-bundle-preflight generate-fixtures /private/tmp/cnc-preflight-fixtures.LTnBpG +target/release/cnc-job-bundle-preflight demo /private/tmp/cnc-preflight-fixtures.LTnBpG /private/tmp/cnc-preflight-staging.xRDZkg +``` + +Observed: generation succeeded. Demo exited 0 because valid was ready and the +nine normal adversarial fixtures were all invalid. Valid staged to +`/private/tmp/cnc-preflight-staging.xRDZkg/valid/ready`; each invalid report had +`ready: false` and `staged: null`. + +```console +rg --files -uu /private/tmp/cnc-preflight-staging.xRDZkg +test ! -e /private/tmp/cnc-preflight-staging.xRDZkg/escape.nc +test ! -e /escape.nc +shasum -a 256 /private/tmp/cnc-preflight-staging.xRDZkg/valid/ready/programs/job.nc +``` + +Observed: the staging tree contained only valid's `manifest.json` and +`programs/job.nc`. Neither escape target existed. The staged program hash was +`b9cea1cced0aa93d046077443fe7ebfd0d5c217d0ba32a0dcc39fa3a18033861`, +matching the manifest and streamed diagnostic evidence. + +### Measured Python baseline + +```console +for f in valid truncated checksum-mismatch undeclared missing duplicate-case absolute-path parent-traversal missing-tool multi-error; do python3 baseline.py "/private/tmp/cnc-preflight-fixtures.LTnBpG/$f.zip"; done +``` + +Observed with Python 3.14.6: only truncated was invalid; all other bundles, +including all content/reference/path adversaries, were ready. + +### Stable independent diagnostics + +```console +target/release/cnc-job-bundle-preflight check /private/tmp/cnc-preflight-fixtures.LTnBpG/multi-error.zip --tools /private/tmp/cnc-preflight-fixtures.LTnBpG/tools.json | shasum -a 256 +target/release/cnc-job-bundle-preflight check /private/tmp/cnc-preflight-fixtures.LTnBpG/multi-error.zip --tools /private/tmp/cnc-preflight-fixtures.LTnBpG/tools.json | shasum -a 256 +``` + +Observed: both complete JSON outputs hashed to +`48eab7f13f5d8badf732bd67030e4e3349279f2c0ff124b9811fd1cfb78235c8`. +The output contained 15 diagnostics sorted by code, path, and message, +including independent archive, checksum, size, missing-file, entry-program, +manifest-version, manifest-format, duplicate, and tool errors. + +### 1,000-file boundary + +```console +target/release/cnc-job-bundle-preflight check /private/tmp/cnc-preflight-fixtures.LTnBpG/thousand-files.zip --tools /private/tmp/cnc-preflight-fixtures.LTnBpG/tools.json +``` + +Observed: `ready: true`, no diagnostics, 1,001 archive members including the +manifest, and 172,118 streamed bytes. + +### 2 GiB streaming and memory + +```console +target/release/cnc-job-bundle-preflight generate-fixtures /private/tmp/cnc-preflight-fixtures.LTnBpG --include-huge +ls -lh /private/tmp/cnc-preflight-fixtures.LTnBpG/oversized-2gib-sparse.zip +du -h /private/tmp/cnc-preflight-fixtures.LTnBpG/oversized-2gib-sparse.zip +/usr/bin/time -l target/release/cnc-job-bundle-preflight check /private/tmp/cnc-preflight-fixtures.LTnBpG/oversized-2gib-sparse.zip --tools /private/tmp/cnc-preflight-fixtures.LTnBpG/tools.json --staging /private/tmp/cnc-preflight-staging.xRDZkg/oversized +``` + +Observed: 2.0 GiB logical length, 20 KiB physical allocation. The final +archived-workspace timed run took 17.00 seconds, streamed 2,147,483,919 bytes +with a reported 65,536-byte buffer, matched the declared SHA-256 and ZIP CRC +(no mismatch diagnostics), and reported 1,998,848 bytes maximum resident set. It returned +exit 1 with only `archive_oversized`, `ready: false`, and `staged: null`. +`/private/tmp/cnc-preflight-staging.xRDZkg/oversized` did not exist afterwards. + +### Symlinked staging root + +```console +ln -s /private/tmp/cnc-preflight-outside.OpDeMm /private/tmp/cnc-preflight-staging-symlink.LTnBpG +target/release/cnc-job-bundle-preflight check /private/tmp/cnc-preflight-fixtures.LTnBpG/valid.zip --tools /private/tmp/cnc-preflight-fixtures.LTnBpG/tools.json --staging /private/tmp/cnc-preflight-staging-symlink.LTnBpG +``` + +Observed: a content-valid archive became `ready: false` with `staging_failed: +selected staging root must be a real directory`; nothing was staged and the +symlink target remained empty. + +## Consequential decision audit + +| Decision | Consequence | Evidence/reversibility | +| --- | --- | --- | +| Use no BogKit crate | Avoids a database and lifecycle that do not help the trust boundary | No core/workspace edits; easy to revisit if requirements become incremental | +| Validate fully before staging | Invalid bundles create no staging output | Demonstrated across nine normal adversaries plus oversized; invalid reports always had `staged: null` | +| Stage through a temporary directory and recheck every copy | A late failure cannot leave incomplete output named `ready`; copied bytes must still match validated content | Two skeptical-review regressions require cleanup and content rechecking before final naming | +| Treat a staging failure as not ready | A valid bundle is never reported ready if its requested write did not complete | Symlink-root and incomplete-copy tests returned no `ready` output | +| Reject compressed, encrypted, multi-disk, and ZIP64 inputs | Safe false negatives; many normal production ZIPs are currently unsupported | Explicit deterministic diagnostics; replace parser behind the same validation interface | +| Set total member cap to 1 GiB | Provides an oversized classification but conflicts with the brief's occasional 2 GiB workload | Prototype-only constant; needs operator/product decision before deployment | +| Require exact UTF-8 names and reject case collisions | Avoids controller/filesystem disagreement | Demonstrated ASCII case collision; Unicode normalization remains uncertain | +| Rename a completed temporary directory to `ready` | Prevents partial output from carrying the final name and avoids overwriting existing work | Existing destinations fail closed; copied content is rechecked; broader filesystem coordination remains unresolved | +| Hash with streaming SHA-256 and verify ZIP CRC | Detects manifest mismatch and archive corruption without member-sized allocation | Known SHA-256 unit vector plus 2 GiB cross-check against precomputed standard digest | + +## Categorized findings + +| Category | Severity | Confidence | Reproduction | Smallest improvement | +| --- | --- | --- | --- | --- | +| Baseline accepts unsafe archive paths | Critical for controller staging | High | Run `baseline.py` on absolute and traversal fixtures | Reject unsafe/case-colliding names before any write | +| Baseline accepts checksum, declaration, size, and tool failures | High | High | Baseline loop above | Parse bounded manifest and stream SHA-256/size/CRC before ready | +| BogKit component fit | Informational: no fit | High | Compare public examples to acceptance criteria | Keep BogKit out unless incremental multi-bundle state becomes a requirement | +| Compressed and ZIP64 bundles rejected | Medium compatibility limitation | High | Create a compressed or ZIP64 input; it fails closed | Use a mature streaming ZIP parser without extraction APIs | +| Staging path checks have local race windows | Medium hardening gap | Medium | Not safely reproduced without a racing local process | Use directory file descriptors plus `openat`/`mkdirat` and no-follow flags | +| 1 GiB policy conflicts with occasional 2 GiB workload | Medium product-policy gap | High | 2 GiB fixture returns only `archive_oversized` | Make a reviewed local configuration or raise the policy with disk-budget checks | +| Unicode normalization aliases are not detected | Low-to-medium portability gap | Medium | Not covered; composed/decomposed names may alias on some filesystems | Normalize to a chosen Unicode form before collision checks | + +## Rejected alternatives + +- **Fold:** durable incremental counts/tables do not make a one-shot bundle + safer, and would add persistent state to a stateless controller gate. +- **ESE and ANNy:** embeddings and nearest-neighbor search do not address any + manifest, archive, memory, or staging criterion. +- **Extract first, validate later:** violates the central trust boundary and + makes absolute/traversal paths consequential before classification. +- **Read each member into memory:** simple but cannot meet the 32 MiB target for + ordinary 10 MB growth or 2 GiB inputs. +- **Shell out to an archive extractor:** harder to make portable and prove + extraction-free; the prototype instead reads member byte ranges directly. +- **Repair malformed bundles:** explicitly outside scope and risks turning an + operator error into silently changed machine input. + +## Skeptical review and coordinator corrections + +The reviewer reproduced the build checks, required fixture classifications, +stable diagnostics, 1,000-file boundary, sparse 2 GiB stream, and the no-fit +comparison. The no-fit label stood: none of the BogKit components supplies the +one-shot archive parsing, manifest validation, or bounded copy gate required by +this prototype. + +The reviewer found two high-severity prototype-quality problems before +archival: incomplete output could retain the final `ready` name after a late +copy failure, and staged content was not compared with the content validated on +the first read. Both are fixed by temporary staging, cleanup, per-copy +byte/CRC/SHA-256 checks, and final rename, with regressions. The reviewer also +required early archive-entry and member-name bounds and narrower compatibility +language for the hand-written classic stored-ZIP subset. + +These were prototype defects, not BogKit defects. No new feature or API +candidate meets the dashboard threshold. + +## Unresolved uncertainty + +- No compressed, ZIP64, data-descriptor, or multi-disk interoperability was + implemented; these fail closed rather than receiving broad parser coverage. +- The parser has unit and generated-fixture coverage but no fuzzing or corpus + testing against independently created ZIP implementations. +- The 2 GiB RSS number is one macOS run. The bounded 64 KiB member buffer and + bounded 65,557-byte end-record scan explain the low result, but other + allocators/platforms should be measured. +- Staging rejects pre-existing symlinks, uses temporary output, rechecks copied + content, and cleans ordinary failures, but it does not provide coordinated + filesystem access against another local process changing paths concurrently. +- Case folding is deterministic, but Unicode normalization and controller + filesystem semantics need an explicit policy. +- The 1 GiB cap was selected to make “oversized” concrete. It is not justified + as the correct operational limit given occasional 2 GiB jobs. +- G-code and tool usage inside program text are intentionally not interpreted; + only manifest-declared tool identifiers are checked against inventory. + +## Deliverable and workspace state + +All prototype source, baseline material, lockfile, usage notes, and this report +are under +`/private/tmp/bogkit-2026-08-01-trial-b.LaXksh/trial-output/cnc-job-bundle-preflight`. +Generated fixtures and staging evidence were kept under separate `/private/tmp` +directories. The root Git status showed only untracked `trial-output/`; no +BogKit core or existing example was edited. The nested `[workspace]` avoided +any root Cargo workspace membership change. diff --git a/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/README.md b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/README.md new file mode 100644 index 0000000..35d63ab --- /dev/null +++ b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/README.md @@ -0,0 +1,43 @@ +# CNC job bundle preflight prototype + +This offline Rust command checks untrusted CNC job ZIPs before staging. It +validates safe names, a versioned JSON manifest, exact/case-colliding names, +declared file presence, undeclared members, byte counts, SHA-256 and ZIP CRC, +entry-program and tool references, and a 1 GiB total-member policy. It stages +only a fully valid bundle through a temporary directory, rechecks every copied +member, and assigns the `ready` name only after the complete copy succeeds. +Incomplete temporary output is removed after an ordinary write failure. + +This prototype intentionally accepts only classic, uncompressed ZIP members. +Compressed, encrypted, multi-disk, and ZIP64 archives fail closed. + +## Build and test + +```console +cargo fmt --all -- --check +cargo test --offline +cargo clippy --offline --all-targets -- -D warnings +cargo build --offline --release +``` + +The nested empty `[workspace]` in `Cargo.toml` keeps this crate isolated from +the surrounding BogKit workspace. It uses no BogKit crate because the trial +found no component that improves a one-shot streaming trust-boundary check. + +## Generate and inspect fixtures + +Keep generated fixtures and staging outside this archiveable directory: + +```console +target/release/cnc-job-bundle-preflight generate-fixtures /tmp/cnc-fixtures --include-huge +target/release/cnc-job-bundle-preflight demo /tmp/cnc-fixtures /tmp/cnc-staging +target/release/cnc-job-bundle-preflight check /tmp/cnc-fixtures/valid.zip \ + --tools /tmp/cnc-fixtures/tools.json --staging /tmp/cnc-one-stage +``` + +`demo` exits successfully only if `valid.zip` is ready and all adversarial +fixtures are invalid. `check` prints deterministic JSON and exits 0 for ready, +1 for invalid, or 2 for command/setup errors. + +`baseline.py BUNDLE.zip` reproduces the filename-only Python comparison. +See `EVIDENCE.md` for the evidence and limitations. diff --git a/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/baseline.py b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/baseline.py new file mode 100644 index 0000000..78b07b6 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/baseline.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Concrete reproducer for the filename-only Python baseline.""" + +import json +import sys +import zipfile + + +def check(path: str) -> dict[str, object]: + errors: list[dict[str, str]] = [] + try: + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + if "manifest.json" not in names: + errors.append({"code": "missing_required", "path": "manifest.json"}) + for name in names: + lowered = name.lower() + if not lowered.endswith((".json", ".nc", ".gcode")): + errors.append({"code": "extension_not_allowed", "path": name}) + except (OSError, zipfile.BadZipFile) as error: + errors.append({"code": "unreadable_zip", "path": str(error)}) + errors.sort(key=lambda item: (item["code"], item["path"])) + return {"ready": not errors, "diagnostics": errors} + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("usage: baseline.py BUNDLE.zip") + print(json.dumps(check(sys.argv[1]), sort_keys=True)) diff --git a/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/archive.rs b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/archive.rs new file mode 100644 index 0000000..b7843c1 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/archive.rs @@ -0,0 +1,345 @@ +use std::fs::File; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::Path; + +const LOCAL_SIGNATURE: u32 = 0x0403_4b50; +const CENTRAL_SIGNATURE: u32 = 0x0201_4b50; +const END_SIGNATURE: u32 = 0x0605_4b50; +const UTF8_FLAG: u16 = 0x0800; +const MAX_ARCHIVE_ENTRIES: usize = 1_001; +const MAX_MEMBER_NAME_BYTES: usize = 4_096; + +#[derive(Debug, Clone)] +pub struct Entry { + pub name: String, + pub compressed_size: u64, + pub size: u64, + pub method: u16, + pub encrypted: bool, + pub crc32: u32, + pub data_offset: u64, +} + +pub struct Archive { + file: File, + pub entries: Vec, +} + +impl Archive { + #[allow(clippy::too_many_lines)] + pub fn open(path: &Path) -> io::Result { + let mut file = File::open(path)?; + let length = file.metadata()?.len(); + let search_length = length.min(65_557); + if search_length < 22 { + return Err(invalid("archive is truncated: no end record")); + } + file.seek(SeekFrom::End( + -i64::try_from(search_length).map_err(|_| invalid("archive tail is too large"))?, + ))?; + let mut tail = vec![ + 0_u8; + usize::try_from(search_length) + .map_err(|_| { invalid("archive tail does not fit in memory") })? + ]; + file.read_exact(&mut tail)?; + let end_index = tail + .windows(4) + .rposition(|window| le_u32(window) == END_SIGNATURE) + .ok_or_else(|| invalid("archive is truncated: no end record"))?; + if tail.len() - end_index < 22 { + return Err(invalid("archive is truncated: partial end record")); + } + let end = &tail[end_index..]; + let disk = le_u16(&end[4..6]); + let central_disk = le_u16(&end[6..8]); + let disk_entries = le_u16(&end[8..10]); + let entry_count = le_u16(&end[10..12]); + let central_size = u64::from(le_u32(&end[12..16])); + let central_offset = u64::from(le_u32(&end[16..20])); + let comment_length = usize::from(le_u16(&end[20..22])); + if disk != 0 || central_disk != 0 || disk_entries != entry_count { + return Err(invalid("multi-disk ZIP archives are unsupported")); + } + if usize::from(entry_count) > MAX_ARCHIVE_ENTRIES { + return Err(invalid("archive exceeds the 1,001-member limit")); + } + if end.len() < 22 + comment_length { + return Err(invalid("archive is truncated: partial ZIP comment")); + } + let central_end = central_offset + .checked_add(central_size) + .ok_or_else(|| invalid("central directory offset overflow"))?; + if central_end > length { + return Err(invalid( + "archive is truncated: central directory exceeds file", + )); + } + + file.seek(SeekFrom::Start(central_offset))?; + let mut entries = Vec::with_capacity(usize::from(entry_count).min(1_001)); + for _ in 0..entry_count { + let mut fixed = [0_u8; 46]; + file.read_exact(&mut fixed) + .map_err(|_| invalid("archive is truncated: partial central entry"))?; + if le_u32(&fixed[..4]) != CENTRAL_SIGNATURE { + return Err(invalid("invalid central directory signature")); + } + let flags = le_u16(&fixed[8..10]); + let method = le_u16(&fixed[10..12]); + let crc32 = le_u32(&fixed[16..20]); + let compressed_size = u64::from(le_u32(&fixed[20..24])); + let size = u64::from(le_u32(&fixed[24..28])); + let name_length = usize::from(le_u16(&fixed[28..30])); + let extra_length = usize::from(le_u16(&fixed[30..32])); + let comment_length = usize::from(le_u16(&fixed[32..34])); + let local_offset = u64::from(le_u32(&fixed[42..46])); + if compressed_size == u64::from(u32::MAX) + || size == u64::from(u32::MAX) + || local_offset == u64::from(u32::MAX) + { + return Err(invalid("ZIP64 archives are unsupported by this prototype")); + } + if name_length > MAX_MEMBER_NAME_BYTES { + return Err(invalid("member name exceeds the 4,096-byte limit")); + } + let mut name = vec![0_u8; name_length]; + file.read_exact(&mut name) + .map_err(|_| invalid("archive is truncated: partial member name"))?; + if flags & UTF8_FLAG == 0 || std::str::from_utf8(&name).is_err() { + return Err(invalid("member name is not explicitly valid UTF-8")); + } + file.seek(SeekFrom::Current( + i64::try_from(extra_length + comment_length) + .map_err(|_| invalid("central metadata is too large"))?, + ))?; + + let return_position = file.stream_position()?; + file.seek(SeekFrom::Start(local_offset))?; + let mut local = [0_u8; 30]; + file.read_exact(&mut local) + .map_err(|_| invalid("archive is truncated: partial local entry"))?; + if le_u32(&local[..4]) != LOCAL_SIGNATURE { + return Err(invalid("invalid local entry signature")); + } + let local_name_length = u64::from(le_u16(&local[26..28])); + let local_extra_length = u64::from(le_u16(&local[28..30])); + let data_offset = local_offset + .checked_add(30) + .and_then(|value| value.checked_add(local_name_length)) + .and_then(|value| value.checked_add(local_extra_length)) + .ok_or_else(|| invalid("local entry offset overflow"))?; + let data_end = data_offset + .checked_add(compressed_size) + .ok_or_else(|| invalid("member data offset overflow"))?; + if data_end > central_offset { + return Err(invalid( + "archive is truncated: member data overlaps central directory", + )); + } + file.seek(SeekFrom::Start(return_position))?; + + entries.push(Entry { + name: String::from_utf8(name).expect("validated UTF-8"), + compressed_size, + size, + method, + encrypted: flags & 1 != 0, + crc32, + data_offset, + }); + } + if file.stream_position()? != central_end { + return Err(invalid("central directory size does not match its entries")); + } + Ok(Self { file, entries }) + } + + pub fn stream_entry( + &mut self, + entry: &Entry, + mut destination: Option<&mut W>, + ) -> io::Result { + if entry.encrypted { + return Err(invalid("encrypted member is unsupported")); + } + if entry.method != 0 || entry.compressed_size != entry.size { + return Err(invalid("only uncompressed ZIP members are supported")); + } + self.file.seek(SeekFrom::Start(entry.data_offset))?; + let mut remaining = entry.compressed_size; + let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice(); + let mut sha = crate::sha256::Sha256::new(); + let mut crc = Crc32::new(); + let mut count = 0_u64; + while remaining != 0 { + let amount = usize::try_from(remaining.min(buffer.len() as u64)) + .expect("bounded by buffer length"); + let read = self.file.read(&mut buffer[..amount])?; + if read == 0 { + return Err(invalid("archive is truncated inside member data")); + } + sha.update(&buffer[..read]); + crc.update(&buffer[..read]); + if let Some(writer) = destination.as_deref_mut() { + writer.write_all(&buffer[..read])?; + } + let read = u64::try_from(read).expect("buffer length fits u64"); + count += read; + remaining -= read; + } + Ok(StreamResult { + bytes: count, + sha256: crate::sha256::hex(&sha.finish()), + crc32: crc.finish(), + }) + } +} + +#[derive(Debug)] +pub struct StreamResult { + pub bytes: u64, + pub sha256: String, + pub crc32: u32, +} + +pub struct Writer { + file: File, + entries: Vec, +} + +struct WrittenEntry { + name: String, + size: u32, + crc32: u32, + local_offset: u32, +} + +impl Writer { + pub fn create(path: &Path) -> io::Result { + Ok(Self { + file: File::create(path)?, + entries: Vec::new(), + }) + } + + pub fn member(&mut self, name: &str, data: &[u8]) -> io::Result<()> { + let mut crc = Crc32::new(); + crc.update(data); + let size = u32::try_from(data.len()).map_err(|_| invalid("fixture member too large"))?; + self.header(name, size, crc.finish())?; + self.file.write_all(data) + } + + pub fn sparse_zero_member(&mut self, name: &str, size: u32, crc32: u32) -> io::Result<()> { + self.header(name, size, crc32)?; + self.file.seek(SeekFrom::Current(i64::from(size)))?; + Ok(()) + } + + fn header(&mut self, name: &str, size: u32, crc32: u32) -> io::Result<()> { + let name_length = u16::try_from(name.len()).map_err(|_| invalid("member name too long"))?; + let local_offset = u32::try_from(self.file.stream_position()?) + .map_err(|_| invalid("fixture exceeds classic ZIP offsets"))?; + write_u32(&mut self.file, LOCAL_SIGNATURE)?; + write_u16(&mut self.file, 20)?; + write_u16(&mut self.file, UTF8_FLAG)?; + write_u16(&mut self.file, 0)?; + write_u16(&mut self.file, 0)?; + write_u16(&mut self.file, 0)?; + write_u32(&mut self.file, crc32)?; + write_u32(&mut self.file, size)?; + write_u32(&mut self.file, size)?; + write_u16(&mut self.file, name_length)?; + write_u16(&mut self.file, 0)?; + self.file.write_all(name.as_bytes())?; + self.entries.push(WrittenEntry { + name: name.to_owned(), + size, + crc32, + local_offset, + }); + Ok(()) + } + + pub fn finish(mut self) -> io::Result<()> { + let central_offset = u32::try_from(self.file.stream_position()?) + .map_err(|_| invalid("fixture exceeds classic ZIP offsets"))?; + for entry in &self.entries { + write_u32(&mut self.file, CENTRAL_SIGNATURE)?; + write_u16(&mut self.file, 20)?; + write_u16(&mut self.file, 20)?; + write_u16(&mut self.file, UTF8_FLAG)?; + write_u16(&mut self.file, 0)?; + write_u16(&mut self.file, 0)?; + write_u16(&mut self.file, 0)?; + write_u32(&mut self.file, entry.crc32)?; + write_u32(&mut self.file, entry.size)?; + write_u32(&mut self.file, entry.size)?; + write_u16( + &mut self.file, + u16::try_from(entry.name.len()).map_err(|_| invalid("member name too long"))?, + )?; + write_u16(&mut self.file, 0)?; + write_u16(&mut self.file, 0)?; + write_u16(&mut self.file, 0)?; + write_u16(&mut self.file, 0)?; + write_u32(&mut self.file, 0)?; + write_u32(&mut self.file, entry.local_offset)?; + self.file.write_all(entry.name.as_bytes())?; + } + let central_end = u32::try_from(self.file.stream_position()?) + .map_err(|_| invalid("fixture exceeds classic ZIP offsets"))?; + let count = u16::try_from(self.entries.len()).map_err(|_| invalid("too many members"))?; + write_u32(&mut self.file, END_SIGNATURE)?; + write_u16(&mut self.file, 0)?; + write_u16(&mut self.file, 0)?; + write_u16(&mut self.file, count)?; + write_u16(&mut self.file, count)?; + write_u32(&mut self.file, central_end - central_offset)?; + write_u32(&mut self.file, central_offset)?; + write_u16(&mut self.file, 0)?; + self.file.flush() + } +} + +pub struct Crc32(u32); + +impl Crc32 { + pub fn new() -> Self { + Self(0xffff_ffff) + } + + pub fn update(&mut self, data: &[u8]) { + for &byte in data { + self.0 ^= u32::from(byte); + for _ in 0..8 { + self.0 = (self.0 >> 1) ^ (0xedb8_8320 & (0_u32.wrapping_sub(self.0 & 1))); + } + } + } + + pub fn finish(self) -> u32 { + !self.0 + } +} + +fn invalid(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +fn le_u16(bytes: &[u8]) -> u16 { + u16::from_le_bytes(bytes[..2].try_into().expect("two bytes")) +} + +fn le_u32(bytes: &[u8]) -> u32 { + u32::from_le_bytes(bytes[..4].try_into().expect("four bytes")) +} + +fn write_u16(writer: &mut File, value: u16) -> io::Result<()> { + writer.write_all(&value.to_le_bytes()) +} + +fn write_u32(writer: &mut File, value: u32) -> io::Result<()> { + writer.write_all(&value.to_le_bytes()) +} diff --git a/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/fixtures.rs b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/fixtures.rs new file mode 100644 index 0000000..c2877b3 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/fixtures.rs @@ -0,0 +1,237 @@ +use std::fs::{self, OpenOptions}; +use std::io; +use std::path::Path; + +use crate::archive::Writer; +use crate::preflight::{FileDeclaration, Inventory, Manifest}; +use crate::sha256::{Sha256, hex}; + +const PROGRAM: &[u8] = b"%\nT01 M06\nG00 X0 Y0\nM30\n%\n"; +const SECOND_PROGRAM: &[u8] = b"%\nT02 M06\nM30\n%\n"; +const TWO_GIB: u32 = 2 * 1024 * 1024 * 1024; +const TWO_GIB_ZERO_SHA256: &str = + "a7c744c13cc101ed66c29f672f92455547889cc586ce6d44fe76ae824958ea51"; +const TWO_GIB_ZERO_CRC32: u32 = 0x4dbd_f21c; + +#[allow(clippy::too_many_lines)] +pub fn generate(output: &Path, include_huge: bool) -> io::Result<()> { + fs::create_dir_all(output)?; + write_json( + &output.join("tools.json"), + &Inventory { + tool_ids: vec!["T01".to_owned(), "T02".to_owned()], + }, + )?; + + let valid = base_manifest(); + bundle( + &output.join("valid.zip"), + &valid, + &[("programs/job.nc", PROGRAM)], + )?; + + let mut mismatch = base_manifest(); + mismatch.files[0].sha256 = "0".repeat(64); + bundle( + &output.join("checksum-mismatch.zip"), + &mismatch, + &[("programs/job.nc", PROGRAM)], + )?; + + bundle( + &output.join("undeclared.zip"), + &base_manifest(), + &[ + ("programs/job.nc", PROGRAM), + ("programs/extra.gcode", SECOND_PROGRAM), + ], + )?; + + let mut missing = base_manifest(); + missing + .files + .push(declaration("programs/missing.nc", SECOND_PROGRAM)); + bundle( + &output.join("missing.zip"), + &missing, + &[("programs/job.nc", PROGRAM)], + )?; + + let duplicate_case = Manifest { + version: 1, + files: vec![ + declaration("programs/job.nc", PROGRAM), + declaration("PROGRAMS/JOB.NC", SECOND_PROGRAM), + ], + entry_program: "programs/job.nc".to_owned(), + required_tool_ids: vec!["T01".to_owned()], + }; + bundle( + &output.join("duplicate-case.zip"), + &duplicate_case, + &[ + ("programs/job.nc", PROGRAM), + ("PROGRAMS/JOB.NC", SECOND_PROGRAM), + ], + )?; + + let absolute = Manifest { + version: 1, + files: vec![declaration("/escape.nc", PROGRAM)], + entry_program: "/escape.nc".to_owned(), + required_tool_ids: vec!["T01".to_owned()], + }; + bundle( + &output.join("absolute-path.zip"), + &absolute, + &[("/escape.nc", PROGRAM)], + )?; + + let traversal = Manifest { + version: 1, + files: vec![declaration("../escape.nc", PROGRAM)], + entry_program: "../escape.nc".to_owned(), + required_tool_ids: vec!["T01".to_owned()], + }; + bundle( + &output.join("parent-traversal.zip"), + &traversal, + &[("../escape.nc", PROGRAM)], + )?; + + let mut missing_tool = base_manifest(); + missing_tool.required_tool_ids = vec!["T404".to_owned()]; + bundle( + &output.join("missing-tool.zip"), + &missing_tool, + &[("programs/job.nc", PROGRAM)], + )?; + + let multi_error = Manifest { + version: 2, + files: vec![ + FileDeclaration { + path: "programs/job.nc".to_owned(), + bytes: 999, + sha256: "0".repeat(64), + }, + declaration("programs/missing.nc", SECOND_PROGRAM), + FileDeclaration { + path: "notes.txt".to_owned(), + bytes: 1, + sha256: "invalid".to_owned(), + }, + declaration("programs/job.nc", PROGRAM), + ], + entry_program: "programs/not-declared.nc".to_owned(), + required_tool_ids: vec!["T404".to_owned(), "T404".to_owned(), String::new()], + }; + bundle( + &output.join("multi-error.zip"), + &multi_error, + &[ + ("programs/job.nc", PROGRAM), + ("programs/extra.gcode", SECOND_PROGRAM), + ], + )?; + + bundle( + &output.join("truncated.zip"), + &base_manifest(), + &[("programs/job.nc", PROGRAM)], + )?; + let truncated = OpenOptions::new() + .write(true) + .open(output.join("truncated.zip"))?; + let shortened = truncated.metadata()?.len().saturating_sub(11); + truncated.set_len(shortened)?; + + thousand_file_bundle(&output.join("thousand-files.zip"))?; + + if include_huge { + oversized_bundle(&output.join("oversized-2gib-sparse.zip"))?; + } + Ok(()) +} + +fn base_manifest() -> Manifest { + Manifest { + version: 1, + files: vec![declaration("programs/job.nc", PROGRAM)], + entry_program: "programs/job.nc".to_owned(), + required_tool_ids: vec!["T01".to_owned()], + } +} + +fn declaration(path: &str, data: &[u8]) -> FileDeclaration { + FileDeclaration { + path: path.to_owned(), + bytes: data.len() as u64, + sha256: digest(data), + } +} + +fn digest(data: &[u8]) -> String { + let mut hash = Sha256::new(); + hash.update(data); + hex(&hash.finish()) +} + +fn bundle(path: &Path, manifest: &Manifest, members: &[(&str, &[u8])]) -> io::Result<()> { + let manifest = serde_json::to_vec_pretty(manifest).map_err(io::Error::other)?; + let mut writer = Writer::create(path)?; + writer.member("manifest.json", &manifest)?; + for (name, data) in members { + writer.member(name, data)?; + } + writer.finish() +} + +fn oversized_bundle(path: &Path) -> io::Result<()> { + let manifest = Manifest { + version: 1, + files: vec![FileDeclaration { + path: "programs/huge.nc".to_owned(), + bytes: u64::from(TWO_GIB), + sha256: TWO_GIB_ZERO_SHA256.to_owned(), + }], + entry_program: "programs/huge.nc".to_owned(), + required_tool_ids: vec!["T01".to_owned()], + }; + let manifest = serde_json::to_vec_pretty(&manifest).map_err(io::Error::other)?; + let mut writer = Writer::create(path)?; + writer.member("manifest.json", &manifest)?; + writer.sparse_zero_member("programs/huge.nc", TWO_GIB, TWO_GIB_ZERO_CRC32)?; + writer.finish() +} + +fn thousand_file_bundle(path: &Path) -> io::Result<()> { + let files: Vec<(String, Vec)> = (0..1_000) + .map(|index| { + let name = format!("programs/job-{index:04}.nc"); + let data = format!("%\n(job {index:04})\nM30\n%\n").into_bytes(); + (name, data) + }) + .collect(); + let manifest = Manifest { + version: 1, + files: files + .iter() + .map(|(name, data)| declaration(name, data)) + .collect(), + entry_program: "programs/job-0000.nc".to_owned(), + required_tool_ids: vec!["T01".to_owned()], + }; + let manifest = serde_json::to_vec_pretty(&manifest).map_err(io::Error::other)?; + let mut writer = Writer::create(path)?; + writer.member("manifest.json", &manifest)?; + for (name, data) in &files { + writer.member(name, data)?; + } + writer.finish() +} + +fn write_json(path: &Path, value: &T) -> io::Result<()> { + let bytes = serde_json::to_vec_pretty(value).map_err(io::Error::other)?; + fs::write(path, bytes) +} diff --git a/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/main.rs b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/main.rs new file mode 100644 index 0000000..f310c17 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/main.rs @@ -0,0 +1,113 @@ +mod archive; +mod fixtures; +mod preflight; +mod sha256; + +use std::env; +use std::path::{Path, PathBuf}; + +use preflight::Report; + +fn main() { + if let Err(message) = run() { + eprintln!("{message}"); + std::process::exit(2); + } +} + +fn run() -> Result<(), String> { + let mut arguments = env::args_os().skip(1); + let Some(command) = arguments.next() else { + return Err(usage()); + }; + match command.to_string_lossy().as_ref() { + "generate-fixtures" => { + let output = arguments.next().ok_or_else(usage)?; + let include_huge = arguments.any(|argument| argument == "--include-huge"); + fixtures::generate(Path::new(&output), include_huge) + .map_err(|error| error.to_string())?; + println!("generated fixtures in {}", Path::new(&output).display()); + Ok(()) + } + "check" => check_command(&arguments.collect::>()), + "demo" => demo_command(&arguments.collect::>()), + _ => Err(usage()), + } +} + +fn check_command(arguments: &[std::ffi::OsString]) -> Result<(), String> { + let bundle = arguments.first().ok_or_else(usage).map(PathBuf::from)?; + let mut tools = None; + let mut staging = None; + let mut index = 1; + while index < arguments.len() { + match arguments[index].to_string_lossy().as_ref() { + "--tools" => { + index += 1; + tools = arguments.get(index).map(PathBuf::from); + } + "--staging" => { + index += 1; + staging = arguments.get(index).map(PathBuf::from); + } + other => return Err(format!("unknown argument {other:?}\n{}", usage())), + } + index += 1; + } + let tools = tools.ok_or_else(usage)?; + let report = preflight::check(&bundle, &tools, staging.as_deref()); + println!( + "{}", + serde_json::to_string_pretty(&report).map_err(|error| error.to_string())? + ); + if report.ready { + Ok(()) + } else { + std::process::exit(1); + } +} + +fn demo_command(arguments: &[std::ffi::OsString]) -> Result<(), String> { + if arguments.len() != 2 { + return Err(usage()); + } + let fixtures = PathBuf::from(&arguments[0]); + let staging = PathBuf::from(&arguments[1]); + let tools = fixtures.join("tools.json"); + let expected = [ + ("valid.zip", true), + ("truncated.zip", false), + ("checksum-mismatch.zip", false), + ("undeclared.zip", false), + ("missing.zip", false), + ("duplicate-case.zip", false), + ("absolute-path.zip", false), + ("parent-traversal.zip", false), + ("missing-tool.zip", false), + ("multi-error.zip", false), + ]; + let mut reports: Vec = Vec::new(); + let mut wrong = Vec::new(); + for (name, should_be_ready) in expected { + let stage = staging.join(name.trim_end_matches(".zip")); + let report = preflight::check(&fixtures.join(name), &tools, Some(&stage)); + if report.ready != should_be_ready { + wrong.push(name); + } + reports.push(report); + } + println!( + "{}", + serde_json::to_string_pretty(&reports).map_err(|error| error.to_string())? + ); + if wrong.is_empty() { + Ok(()) + } else { + Err(format!("unexpected classifications: {}", wrong.join(", "))) + } +} + +fn usage() -> String { + "usage:\n cnc-job-bundle-preflight generate-fixtures DIR [--include-huge]\n cnc-job-bundle-preflight check BUNDLE --tools INVENTORY [--staging DIR]\n cnc-job-bundle-preflight demo FIXTURE_DIR STAGING_ROOT" + .to_owned() +} diff --git a/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/preflight.rs b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/preflight.rs new file mode 100644 index 0000000..3b155eb --- /dev/null +++ b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/preflight.rs @@ -0,0 +1,923 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; + +use crate::archive::{Archive, Entry}; + +const MAX_FILES: usize = 1_000; +const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; +const MAX_TOTAL_BYTES: u64 = 1024 * 1024 * 1024; +const STREAM_BUFFER_BYTES: u64 = 64 * 1024; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Manifest { + pub version: u32, + pub files: Vec, + pub entry_program: String, + pub required_tool_ids: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FileDeclaration { + pub path: String, + pub bytes: u64, + pub sha256: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Inventory { + pub tool_ids: Vec, +} + +#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct Diagnostic { + pub code: String, + pub path: String, + pub message: String, +} + +#[derive(Debug, Default, Serialize)] +pub struct Metrics { + pub archive_members: usize, + pub streamed_bytes: u64, + pub stream_buffer_bytes: u64, + pub total_member_bytes: u64, +} + +#[derive(Debug, Serialize)] +pub struct Report { + pub bundle: String, + pub ready: bool, + pub staged: Option, + pub diagnostics: Vec, + pub metrics: Metrics, +} + +struct LoadedManifest { + value: Manifest, + raw_sha256: String, +} + +pub fn check(bundle: &Path, inventory_path: &Path, staging: Option<&Path>) -> Report { + let mut diagnostics = Vec::new(); + let mut metrics = Metrics { + stream_buffer_bytes: STREAM_BUFFER_BYTES, + ..Metrics::default() + }; + let inventory = load_inventory(inventory_path, &mut diagnostics); + let mut archive = match Archive::open(bundle) { + Ok(archive) => archive, + Err(error) => { + add(&mut diagnostics, "archive_invalid", "", &error.to_string()); + diagnostics.sort(); + return Report { + bundle: bundle.display().to_string(), + ready: false, + staged: None, + diagnostics, + metrics, + }; + } + }; + metrics.archive_members = archive.entries.len(); + metrics.total_member_bytes = archive.entries.iter().map(|entry| entry.size).sum(); + + validate_archive_inventory(&archive.entries, &mut diagnostics); + if archive.entries.len().saturating_sub(1) > MAX_FILES { + add( + &mut diagnostics, + "archive_too_many_files", + "", + &format!("maximum is {MAX_FILES}"), + ); + } + if metrics.total_member_bytes > MAX_TOTAL_BYTES { + add( + &mut diagnostics, + "archive_oversized", + "", + &format!( + "{} bytes exceeds {} byte policy", + metrics.total_member_bytes, MAX_TOTAL_BYTES + ), + ); + } + + let manifest_indexes: Vec = archive + .entries + .iter() + .enumerate() + .filter_map(|(index, entry)| (entry.name == "manifest.json").then_some(index)) + .collect(); + if manifest_indexes.is_empty() { + add( + &mut diagnostics, + "manifest_missing", + "manifest.json", + "exact root member is required", + ); + } else if manifest_indexes.len() > 1 { + add( + &mut diagnostics, + "manifest_duplicate", + "manifest.json", + "manifest must occur exactly once", + ); + } + + let manifest = manifest_indexes.first().and_then(|&index| { + let entry = archive.entries[index].clone(); + read_manifest(&mut archive, &entry, &mut diagnostics, &mut metrics) + }); + + if let Some(manifest) = &manifest { + validate_manifest( + &mut archive, + &manifest.value, + inventory.as_ref(), + &mut diagnostics, + &mut metrics, + ); + } + + diagnostics.sort(); + diagnostics.dedup(); + let mut staged = None; + if diagnostics.is_empty() + && let (Some(staging), Some(manifest)) = (staging, manifest.as_ref()) + { + match stage(&mut archive, &manifest.value, &manifest.raw_sha256, staging) { + Ok(path) => staged = Some(path.display().to_string()), + Err(error) => add(&mut diagnostics, "staging_failed", "", &error.to_string()), + } + } + diagnostics.sort(); + Report { + bundle: bundle.display().to_string(), + ready: diagnostics.is_empty(), + staged, + diagnostics, + metrics, + } +} + +fn load_inventory(path: &Path, diagnostics: &mut Vec) -> Option { + match fs::read(path) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(inventory) => { + let mut seen = BTreeSet::new(); + for tool in &inventory.tool_ids { + if tool.is_empty() { + add( + diagnostics, + "inventory_tool_id_empty", + "", + "tool IDs cannot be empty", + ); + } else if !seen.insert(tool) { + add( + diagnostics, + "inventory_tool_id_duplicate", + tool, + "tool ID occurs more than once", + ); + } + } + Some(inventory) + } + Err(error) => { + add(diagnostics, "inventory_invalid", "", &error.to_string()); + None + } + }, + Err(error) => { + add(diagnostics, "inventory_unreadable", "", &error.to_string()); + None + } + } +} + +fn validate_archive_inventory(entries: &[Entry], diagnostics: &mut Vec) { + let mut exact: BTreeMap<&str, usize> = BTreeMap::new(); + let mut folded: BTreeMap> = BTreeMap::new(); + for entry in entries { + if let Err(reason) = safe_relative_path(&entry.name) { + add(diagnostics, "archive_path_unsafe", &entry.name, reason); + } + *exact.entry(&entry.name).or_default() += 1; + folded + .entry(entry.name.to_lowercase()) + .or_default() + .push(&entry.name); + if entry.encrypted { + add( + diagnostics, + "archive_member_encrypted", + &entry.name, + "encrypted members are unsupported", + ); + } + if entry.method != 0 || entry.compressed_size != entry.size { + add( + diagnostics, + "archive_compression_unsupported", + &entry.name, + "prototype requires stored members", + ); + } + } + add_path_prefix_collisions( + exact.keys().copied(), + diagnostics, + "archive_path_type_collision", + ); + for (path, count) in exact { + if count > 1 { + add( + diagnostics, + "archive_name_duplicate", + path, + "member name occurs more than once", + ); + } + } + for names in folded.values_mut() { + names.sort_unstable(); + names.dedup(); + if names.len() > 1 { + add( + diagnostics, + "archive_name_case_collision", + &names.join(" | "), + "member names differ only by case", + ); + } + } +} + +fn read_manifest( + archive: &mut Archive, + entry: &Entry, + diagnostics: &mut Vec, + metrics: &mut Metrics, +) -> Option { + if entry.size > MAX_MANIFEST_BYTES { + add( + diagnostics, + "manifest_oversized", + "manifest.json", + "manifest exceeds 1 MiB", + ); + return None; + } + let mut bytes = Vec::with_capacity(usize::try_from(entry.size).unwrap_or(0)); + match archive.stream_entry(entry, Some(&mut bytes)) { + Ok(result) => { + metrics.streamed_bytes += result.bytes; + if result.crc32 != entry.crc32 { + add( + diagnostics, + "archive_crc_mismatch", + "manifest.json", + "ZIP CRC does not match content", + ); + } + match serde_json::from_slice(&bytes) { + Ok(value) => Some(LoadedManifest { + value, + raw_sha256: result.sha256, + }), + Err(error) => { + add( + diagnostics, + "manifest_invalid", + "manifest.json", + &error.to_string(), + ); + None + } + } + } + Err(error) => { + add( + diagnostics, + "manifest_unreadable", + "manifest.json", + &error.to_string(), + ); + None + } + } +} + +#[allow(clippy::too_many_lines)] +fn validate_manifest( + archive: &mut Archive, + manifest: &Manifest, + inventory: Option<&Inventory>, + diagnostics: &mut Vec, + metrics: &mut Metrics, +) { + if manifest.version != 1 { + add( + diagnostics, + "manifest_version_unsupported", + "manifest.json", + "only version 1 is supported", + ); + } + if manifest.files.len() > MAX_FILES { + add( + diagnostics, + "manifest_too_many_files", + "manifest.json", + &format!("maximum is {MAX_FILES}"), + ); + } + + let mut declarations: BTreeMap<&str, &FileDeclaration> = BTreeMap::new(); + let mut folded: BTreeMap> = BTreeMap::new(); + for declaration in &manifest.files { + if let Err(reason) = safe_relative_path(&declaration.path) { + add( + diagnostics, + "manifest_path_unsafe", + &declaration.path, + reason, + ); + } + let allowed_extension = Path::new(&declaration.path) + .extension() + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("nc") || extension.eq_ignore_ascii_case("gcode") + }); + if !allowed_extension { + add( + diagnostics, + "manifest_extension_disallowed", + &declaration.path, + "program must end in .nc or .gcode", + ); + } + if !valid_sha256(&declaration.sha256) { + add( + diagnostics, + "manifest_checksum_invalid", + &declaration.path, + "SHA-256 must be 64 lowercase hexadecimal characters", + ); + } + if declarations + .insert(&declaration.path, declaration) + .is_some() + { + add( + diagnostics, + "manifest_path_duplicate", + &declaration.path, + "path is declared more than once", + ); + } + folded + .entry(declaration.path.to_lowercase()) + .or_default() + .push(&declaration.path); + } + for names in folded.values_mut() { + names.sort_unstable(); + names.dedup(); + if names.len() > 1 { + add( + diagnostics, + "manifest_path_case_collision", + &names.join(" | "), + "declared paths differ only by case", + ); + } + } + add_path_prefix_collisions( + declarations.keys().copied(), + diagnostics, + "manifest_path_type_collision", + ); + + if safe_relative_path(&manifest.entry_program).is_err() { + add( + diagnostics, + "entry_program_path_unsafe", + &manifest.entry_program, + "entry program must be a safe relative path", + ); + } + if !declarations.contains_key(manifest.entry_program.as_str()) { + add( + diagnostics, + "entry_program_undeclared", + &manifest.entry_program, + "entry program must be declared", + ); + } + + let archive_by_name: BTreeMap<&str, &Entry> = archive + .entries + .iter() + .filter(|entry| entry.name != "manifest.json") + .map(|entry| (entry.name.as_str(), entry)) + .collect(); + for path in declarations.keys() { + if !archive_by_name.contains_key(path) { + add( + diagnostics, + "declared_file_missing", + path, + "manifest path is absent from archive", + ); + } + } + for path in archive_by_name.keys() { + if !declarations.contains_key(path) { + add( + diagnostics, + "archive_file_undeclared", + path, + "archive member is absent from manifest", + ); + } + } + + if let Some(inventory) = inventory { + let available: BTreeSet<&str> = inventory.tool_ids.iter().map(String::as_str).collect(); + let mut requested = BTreeSet::new(); + for tool in &manifest.required_tool_ids { + if tool.is_empty() { + add( + diagnostics, + "manifest_tool_id_empty", + "", + "required tool IDs cannot be empty", + ); + } else if !requested.insert(tool) { + add( + diagnostics, + "manifest_tool_id_duplicate", + tool, + "required tool ID occurs more than once", + ); + } + if !available.contains(tool.as_str()) { + add( + diagnostics, + "required_tool_missing", + tool, + "tool ID is absent from inventory", + ); + } + } + } + + let declared_entries: Vec<(FileDeclaration, Entry)> = manifest + .files + .iter() + .filter_map(|declaration| { + archive_by_name + .get(declaration.path.as_str()) + .map(|entry| (declaration.clone(), (*entry).clone())) + }) + .collect(); + for (declaration, entry) in declared_entries { + if declaration.bytes != entry.size { + add( + diagnostics, + "declared_size_mismatch", + &declaration.path, + &format!( + "manifest says {} bytes; ZIP says {}", + declaration.bytes, entry.size + ), + ); + } + match archive.stream_entry::(&entry, None) { + Ok(result) => { + metrics.streamed_bytes += result.bytes; + if result.bytes != declaration.bytes { + add( + diagnostics, + "content_size_mismatch", + &declaration.path, + &format!( + "manifest says {} bytes; streamed {}", + declaration.bytes, result.bytes + ), + ); + } + if result.sha256 != declaration.sha256 { + add( + diagnostics, + "checksum_mismatch", + &declaration.path, + &format!("expected {}; got {}", declaration.sha256, result.sha256), + ); + } + if result.crc32 != entry.crc32 { + add( + diagnostics, + "archive_crc_mismatch", + &declaration.path, + "ZIP CRC does not match content", + ); + } + } + Err(error) => add( + diagnostics, + "member_unreadable", + &declaration.path, + &error.to_string(), + ), + } + } +} + +fn stage( + archive: &mut Archive, + manifest: &Manifest, + manifest_sha256: &str, + staging: &Path, +) -> std::io::Result { + if staging.exists() { + let metadata = fs::symlink_metadata(staging)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(std::io::Error::other( + "selected staging root must be a real directory", + )); + } + } else { + fs::create_dir(staging)?; + } + let ready = staging.join("ready"); + match fs::symlink_metadata(&ready) { + Ok(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "ready destination already exists", + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + + let pending = create_pending_directory(staging)?; + let copy_result = stage_into(archive, manifest, manifest_sha256, &pending); + if let Err(error) = copy_result { + let _ = fs::remove_dir_all(&pending); + return Err(error); + } + if let Err(error) = fs::rename(&pending, &ready) { + let _ = fs::remove_dir_all(&pending); + return Err(error); + } + Ok(ready) +} + +fn stage_into( + archive: &mut Archive, + manifest: &Manifest, + manifest_sha256: &str, + pending: &Path, +) -> std::io::Result<()> { + let declarations: BTreeMap<&str, &FileDeclaration> = manifest + .files + .iter() + .map(|declaration| (declaration.path.as_str(), declaration)) + .collect(); + let entries = archive.entries.clone(); + for entry in entries { + let relative = safe_relative_path(&entry.name).map_err(std::io::Error::other)?; + let destination = pending.join(&relative); + if !destination.starts_with(pending) { + return Err(std::io::Error::other("destination escaped staging")); + } + if let Some(parent) = destination.parent() { + create_safe_directories(pending, parent)?; + } + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&destination)?; + let copied = archive.stream_entry(&entry, Some(&mut file))?; + file.flush()?; + file.sync_all()?; + + if copied.bytes != entry.size || copied.crc32 != entry.crc32 { + return Err(std::io::Error::other(format!( + "copied content changed for {:?}", + entry.name + ))); + } + let expected_sha256 = if entry.name == "manifest.json" { + manifest_sha256 + } else { + declarations + .get(entry.name.as_str()) + .ok_or_else(|| std::io::Error::other("staging member was not declared"))? + .sha256 + .as_str() + }; + if copied.sha256 != expected_sha256 { + return Err(std::io::Error::other(format!( + "copied content changed for {:?}", + entry.name + ))); + } + } + Ok(()) +} + +fn create_pending_directory(staging: &Path) -> std::io::Result { + static NEXT_PENDING: AtomicU64 = AtomicU64::new(0); + for _ in 0..100 { + let sequence = NEXT_PENDING.fetch_add(1, Ordering::Relaxed); + let pending = staging.join(format!(".ready.pending-{}-{sequence}", std::process::id())); + match fs::create_dir(&pending) { + Ok(()) => return Ok(pending), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + } + Err(std::io::Error::other( + "could not allocate a temporary staging directory", + )) +} + +fn create_safe_directories(root: &Path, parent: &Path) -> std::io::Result<()> { + let relative = parent + .strip_prefix(root) + .map_err(|_| std::io::Error::other("parent escaped staging"))?; + let mut current = root.to_path_buf(); + for component in relative.components() { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(std::io::Error::other( + "staging path contains a non-directory or symlink", + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(¤t)?; + } + Err(error) => return Err(error), + } + } + Ok(()) +} + +pub fn safe_relative_path(path: &str) -> Result { + if path.is_empty() { + return Err("path is empty"); + } + if path.contains('\\') { + return Err("backslash separators are forbidden"); + } + if path.contains('\0') { + return Err("NUL bytes are forbidden"); + } + if path.starts_with('/') || path.starts_with("//") { + return Err("absolute paths are forbidden"); + } + let bytes = path.as_bytes(); + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + return Err("drive-prefixed paths are forbidden"); + } + let candidate = Path::new(path); + let mut output = PathBuf::new(); + for component in candidate.components() { + match component { + Component::Normal(value) => output.push(value), + Component::ParentDir => return Err("parent traversal is forbidden"), + Component::CurDir => return Err("dot segments are forbidden"), + Component::RootDir | Component::Prefix(_) => { + return Err("absolute paths are forbidden"); + } + } + } + if path.split('/').any(str::is_empty) { + return Err("empty path segments are forbidden"); + } + Ok(output) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn add_path_prefix_collisions<'a>( + paths: impl IntoIterator, + diagnostics: &mut Vec, + code: &str, +) { + let paths: BTreeSet<&str> = paths.into_iter().collect(); + for path in &paths { + for (index, byte) in path.bytes().enumerate() { + if byte == b'/' { + let parent = &path[..index]; + if paths.contains(parent) { + add( + diagnostics, + code, + &format!("{parent} | {path}"), + "one file path is also the parent of another file path", + ); + } + } + } + } +} + +fn add(diagnostics: &mut Vec, code: &str, path: &str, message: &str) { + diagnostics.push(Diagnostic { + code: code.to_owned(), + path: path.to_owned(), + message: message.to_owned(), + }); +} + +#[cfg(test)] +mod tests { + use std::fs::{self, OpenOptions}; + use std::io::{Seek, SeekFrom, Write}; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::{FileDeclaration, Manifest, add_path_prefix_collisions, safe_relative_path, stage}; + use crate::archive::{Archive, Writer}; + use crate::sha256::{Sha256, hex}; + + #[test] + fn accepts_safe_program_path() { + assert!(safe_relative_path("programs/job.nc").is_ok()); + } + + #[test] + fn rejects_escape_forms() { + for path in [ + "/etc/passwd", + "../escape.nc", + "a/../../escape.nc", + "C:/escape.nc", + r"..\escape.nc", + "a//b.nc", + "./job.nc", + ] { + assert!(safe_relative_path(path).is_err(), "accepted {path:?}"); + } + } + + #[test] + fn reports_file_and_parent_path_collisions() { + let mut diagnostics = Vec::new(); + add_path_prefix_collisions( + ["programs/job.nc", "programs/job.nc/child.nc"], + &mut diagnostics, + "path_type_collision", + ); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].code, "path_type_collision"); + } + + #[test] + fn late_copy_failure_leaves_no_ready_or_pending_directory() { + let root = temporary_directory("late-copy"); + let bundle = root.join("bundle.zip"); + let staging = root.join("staging"); + let first = b"%\nM30\n"; + let second = b"%\nM31\n"; + let manifest = Manifest { + version: 1, + files: vec![ + declaration("programs/job.nc", first), + declaration("programs/job.nc/child.nc", second), + ], + entry_program: "programs/job.nc".to_owned(), + required_tool_ids: Vec::new(), + }; + let manifest_sha256 = write_bundle( + &bundle, + &manifest, + &[ + ("programs/job.nc", first.as_slice()), + ("programs/job.nc/child.nc", second.as_slice()), + ], + ); + let mut archive = Archive::open(&bundle).expect("open generated bundle"); + + let error = stage(&mut archive, &manifest, &manifest_sha256, &staging) + .expect_err("copy conflict must fail"); + assert!(!error.to_string().is_empty()); + assert!(!staging.join("ready").exists()); + assert_eq!( + fs::read_dir(&staging).expect("read staging root").count(), + 0 + ); + fs::remove_dir_all(&root).expect("remove test directory"); + } + + #[test] + fn copied_content_is_rechecked_before_ready_is_named() { + let root = temporary_directory("copy-recheck"); + let bundle = root.join("bundle.zip"); + let staging = root.join("staging"); + let original = b"%\nM30\n"; + let replacement = b"%\nM31\n"; + let manifest = Manifest { + version: 1, + files: vec![declaration("programs/job.nc", original)], + entry_program: "programs/job.nc".to_owned(), + required_tool_ids: Vec::new(), + }; + let manifest_sha256 = write_bundle( + &bundle, + &manifest, + &[("programs/job.nc", original.as_slice())], + ); + let mut archive = Archive::open(&bundle).expect("open generated bundle"); + let data_offset = archive + .entries + .iter() + .find(|entry| entry.name == "programs/job.nc") + .expect("program entry") + .data_offset; + let mut source = OpenOptions::new() + .write(true) + .open(&bundle) + .expect("open bundle for ordinary update"); + source + .seek(SeekFrom::Start(data_offset)) + .expect("seek to program data"); + source + .write_all(replacement) + .expect("replace program bytes"); + source.flush().expect("flush replacement"); + + let error = stage(&mut archive, &manifest, &manifest_sha256, &staging) + .expect_err("changed copy must fail"); + assert!(error.to_string().contains("copied content changed")); + assert!(!staging.join("ready").exists()); + assert_eq!( + fs::read_dir(&staging).expect("read staging root").count(), + 0 + ); + fs::remove_dir_all(&root).expect("remove test directory"); + } + + fn temporary_directory(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "cnc-job-bundle-preflight-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&path).expect("create test directory"); + path + } + + fn declaration(path: &str, data: &[u8]) -> FileDeclaration { + FileDeclaration { + path: path.to_owned(), + bytes: data.len() as u64, + sha256: digest(data), + } + } + + fn digest(data: &[u8]) -> String { + let mut sha = Sha256::new(); + sha.update(data); + hex(&sha.finish()) + } + + fn write_bundle(path: &Path, manifest: &Manifest, members: &[(&str, &[u8])]) -> String { + let manifest_bytes = serde_json::to_vec(manifest).expect("serialize manifest"); + let manifest_sha256 = digest(&manifest_bytes); + let mut writer = Writer::create(path).expect("create bundle"); + writer + .member("manifest.json", &manifest_bytes) + .expect("write manifest"); + for (name, data) in members { + writer.member(name, data).expect("write member"); + } + writer.finish().expect("finish bundle"); + manifest_sha256 + } +} diff --git a/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/sha256.rs b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/sha256.rs new file mode 100644 index 0000000..c44d391 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--cnc-job-bundle-preflight/src/sha256.rs @@ -0,0 +1,211 @@ +//! Small streaming SHA-256 implementation used to keep the prototype offline. + +pub struct Sha256 { + state: [u32; 8], + buffer: [u8; 64], + buffered: usize, + bytes: u64, +} + +const INITIAL: [u32; 8] = [ + 0x6a09_e667, + 0xbb67_ae85, + 0x3c6e_f372, + 0xa54f_f53a, + 0x510e_527f, + 0x9b05_688c, + 0x1f83_d9ab, + 0x5be0_cd19, +]; + +const K: [u32; 64] = [ + 0x428a_2f98, + 0x7137_4491, + 0xb5c0_fbcf, + 0xe9b5_dba5, + 0x3956_c25b, + 0x59f1_11f1, + 0x923f_82a4, + 0xab1c_5ed5, + 0xd807_aa98, + 0x1283_5b01, + 0x2431_85be, + 0x550c_7dc3, + 0x72be_5d74, + 0x80de_b1fe, + 0x9bdc_06a7, + 0xc19b_f174, + 0xe49b_69c1, + 0xefbe_4786, + 0x0fc1_9dc6, + 0x240c_a1cc, + 0x2de9_2c6f, + 0x4a74_84aa, + 0x5cb0_a9dc, + 0x76f9_88da, + 0x983e_5152, + 0xa831_c66d, + 0xb003_27c8, + 0xbf59_7fc7, + 0xc6e0_0bf3, + 0xd5a7_9147, + 0x06ca_6351, + 0x1429_2967, + 0x27b7_0a85, + 0x2e1b_2138, + 0x4d2c_6dfc, + 0x5338_0d13, + 0x650a_7354, + 0x766a_0abb, + 0x81c2_c92e, + 0x9272_2c85, + 0xa2bf_e8a1, + 0xa81a_664b, + 0xc24b_8b70, + 0xc76c_51a3, + 0xd192_e819, + 0xd699_0624, + 0xf40e_3585, + 0x106a_a070, + 0x19a4_c116, + 0x1e37_6c08, + 0x2748_774c, + 0x34b0_bcb5, + 0x391c_0cb3, + 0x4ed8_aa4a, + 0x5b9c_ca4f, + 0x682e_6ff3, + 0x748f_82ee, + 0x78a5_636f, + 0x84c8_7814, + 0x8cc7_0208, + 0x90be_fffa, + 0xa450_6ceb, + 0xbef9_a3f7, + 0xc671_78f2, +]; + +impl Sha256 { + pub fn new() -> Self { + Self { + state: INITIAL, + buffer: [0; 64], + buffered: 0, + bytes: 0, + } + } + + pub fn update(&mut self, mut input: &[u8]) { + self.bytes = self.bytes.saturating_add(input.len() as u64); + if self.buffered != 0 { + let amount = (64 - self.buffered).min(input.len()); + self.buffer[self.buffered..self.buffered + amount].copy_from_slice(&input[..amount]); + self.buffered += amount; + input = &input[amount..]; + if self.buffered == 64 { + let block = self.buffer; + self.compress(&block); + self.buffered = 0; + } + } + while input.len() >= 64 { + let (block, rest) = input.split_at(64); + self.compress(block.try_into().expect("64-byte chunk")); + input = rest; + } + self.buffer[..input.len()].copy_from_slice(input); + self.buffered = input.len(); + } + + pub fn finish(mut self) -> [u8; 32] { + let bit_len = self.bytes.wrapping_mul(8); + self.buffer[self.buffered] = 0x80; + self.buffered += 1; + if self.buffered > 56 { + self.buffer[self.buffered..].fill(0); + let block = self.buffer; + self.compress(&block); + self.buffered = 0; + } + self.buffer[self.buffered..56].fill(0); + self.buffer[56..].copy_from_slice(&bit_len.to_be_bytes()); + let block = self.buffer; + self.compress(&block); + + let mut output = [0; 32]; + for (chunk, word) in output.chunks_exact_mut(4).zip(self.state) { + chunk.copy_from_slice(&word.to_be_bytes()); + } + output + } + + #[allow(clippy::many_single_char_names)] + fn compress(&mut self, block: &[u8; 64]) { + let mut schedule = [0_u32; 64]; + for (word, chunk) in schedule[..16].iter_mut().zip(block.chunks_exact(4)) { + *word = u32::from_be_bytes(chunk.try_into().expect("4-byte chunk")); + } + for i in 16..64 { + let s0 = schedule[i - 15].rotate_right(7) + ^ schedule[i - 15].rotate_right(18) + ^ (schedule[i - 15] >> 3); + let s1 = schedule[i - 2].rotate_right(17) + ^ schedule[i - 2].rotate_right(19) + ^ (schedule[i - 2] >> 10); + schedule[i] = schedule[i - 16] + .wrapping_add(s0) + .wrapping_add(schedule[i - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state; + for (&constant, &word) in K.iter().zip(&schedule) { + let upper = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choice = (e & f) ^ ((!e) & g); + let first = h + .wrapping_add(upper) + .wrapping_add(choice) + .wrapping_add(constant) + .wrapping_add(word); + let lower = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let second = lower.wrapping_add(majority); + h = g; + g = f; + f = e; + e = d.wrapping_add(first); + d = c; + c = b; + b = a; + a = first.wrapping_add(second); + } + for (state, value) in self.state.iter_mut().zip([a, b, c, d, e, f, g, h]) { + *state = state.wrapping_add(value); + } + } +} + +pub fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + output.push(char::from(DIGITS[usize::from(byte >> 4)])); + output.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + output +} + +#[cfg(test)] +mod tests { + use super::{Sha256, hex}; + + #[test] + fn known_vector() { + let mut hash = Sha256::new(); + hash.update(b"abc"); + assert_eq!( + hex(&hash.finish()), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } +} diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/Cargo.toml b/developer-simulation/runs/2026-08-01--offline-flag-parity/Cargo.toml new file mode 100644 index 0000000..121c85d --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "offline-flag-parity" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/EVIDENCE.md b/developer-simulation/runs/2026-08-01--offline-flag-parity/EVIDENCE.md new file mode 100644 index 0000000..d64ee43 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/EVIDENCE.md @@ -0,0 +1,394 @@ +# Offline Flag Parity Trial Report + +Run date: 2026-08-01 EDT +Persona: client-platform SDK developer, production TypeScript experience, Rust beginner +Sanitized checkout: `/private/tmp/bogkit-2026-08-01-trial-a.AF7gv4` +Prototype: `/private/tmp/bogkit-2026-08-01-trial-a.AF7gv4/trial-output/offline-flag-parity` + +## Outcome + +**Runnable prototype; BogKit no-fit for this bounded workload.** + +The direct baseline—validate a complete JSON candidate, then atomically replace +one immutable in-memory snapshot—was smaller and more directly aligned than a +durable incremental database. I therefore used no BogKit crate and did not edit +BogKit core or its examples. + +The prototype met the trial's measured checks: + +- 250 stored golden decisions matched in Rust and in a dependency-free + TypeScript-style JavaScript reference evaluator. +- Reversing every JSON object's key order left all 250 decisions and full + explanations unchanged in both evaluators. +- All 12 malformed fixtures were rejected. A bad reload left the last good + in-memory snapshot and its decisions unchanged. +- Ten fresh CLI processes produced the one fingerprint `99c04dd07bb96094`. +- Four clean release benchmark processes each ran five 20,000-evaluation + batches against 5,000 flags and 50,000 rules. Across the 20 batches, p95 was + 1.000–1.333 microseconds; all were below 250 microseconds. +- The highest sampled same-size reload RSS across the developer and + post-review runs for the generated 6.2 MB, 5,000-flag/50,000-rule workload + was 63,569,920 bytes, below the + 67,108,864-byte (64 MiB) ceiling. This is a sampled process-level + measurement for that workload, not a guarantee for every accepted file or a + proof that no shorter spike occurred. + +## Brief + +Build a local library and CLI that loads generated JSON flag snapshots, +evaluates ordered rules against flat scalar contexts without network access, +records a rule-level explanation, retains the active configuration after a bad +reload, uses stable percentage assignment across machines and restarts, and +demonstrates correctness and speed with synthetic fixtures. + +Target workload: up to 5,000 flags, 50,000 rules, and about 100,000 evaluations +per minute within 64 MiB working memory. Non-goals were authoring, delivery, +analytics, UI, authentication, and commercial-format compatibility. + +## Ordered discovery, friction, and debugging trail + +1. Read only the public root `README.md`. It presented Fold as a durable, + transactional incremental-programming framework, ESE as static embeddings, + ANNy as nearest-neighbor search, and four public examples. +2. Listed public example files, then read them in this order: `chat`, `search`, + `starter`, `timeseries` (each manifest followed by its `src/main.rs`). The + examples showed transactional writes, persistent sinks, consistent reads, + and incremental aggregation/search. None showed configuration admission or + an immutable hot-path rules evaluator. +3. Before inspecting any BogKit implementation, defined the concrete baseline: + parse a candidate snapshot completely; reject syntax, duplicate keys, + unknown fields, invalid limits, and invalid rule semantics; retain the + current `Arc` until validation succeeds; evaluate rule arrays in + order; use a specified stable hash rather than a process-randomized map hash; + and return the trace of every visited rule. +4. Inspected the root workspace manifest, Fold manifest, and the Fold stream + transaction/snapshot API. Compared those capabilities with the baseline. + Fold can atomically update durable materialized views, but it does not remove + the need to parse and validate the entire candidate before activation. A + database on the evaluation path would add files, lifecycle work, and memory + without improving this prototype's decision lookup. +5. Chose no BogKit component. Added an independent Cargo package under + `trial-output/offline-flag-parity` with its own empty `[workspace]` table, so + no parent-workspace edit or membership was needed. +6. Implemented the Rust library and CLI. The first ordinary `cargo test` tried + to update the crates.io index and failed because DNS/network was unavailable. + Re-running with `--offline` used cached dependencies and succeeded. All + subsequent Cargo evidence commands used `--offline` where dependency + resolution was involved. +7. The first fixed-bucket unit test contained my incorrect expected number + (`7452`). The Rust implementation returned `7307`. I did not accept that + value only because the implementation produced it: an independent Node + `BigInt` FNV-1a calculation also returned `7307`, after which the fixed known + vector was corrected. +8. Strict Clippy initially found two manual modulo expressions. Replaced them + with the standard `is_multiple_of` form and reran formatting and Clippy. +9. Generated 250 golden contexts, a normal snapshot, a recursively + reverse-object-key-order snapshot, good/bad reloads, and 12 malformed + snapshots. Rust verification passed all fixtures. +10. The first full benchmark built, serialized, and loaded the 50,000-rule + snapshot inside one process. macOS retained temporary generator allocations, + and sampled RSS reached 146,587,648 bytes, failing the 64 MiB requirement. + This was treated as a real failure, not reported as a passing evaluator. +11. Changed duplicate-key validation from a retained generic JSON tree to a + streaming preflight, followed by direct typed deserialization. Separated the + deterministic large-fixture generator into its own CLI process. The clean + benchmark process then loaded an existing snapshot and performed a + same-size reload while the old snapshot remained active. +12. The sandbox blocked `/bin/ps` and made `/usr/bin/time -l` fail its macOS + system query. The final benchmark was run with read-only access to its own + process statistics. Four clean runs passed latency and sampled RSS limits. +13. Added and ran a dependency-free TypeScript-style reference evaluator. It + independently matched all 250 Rust golden decisions, their explanations, + reordered-object results, and the known bucket vector. +14. Preserved the small 276 KiB fixture corpus in the prototype, reran ten fresh + process fingerprints, and began the final validation sweep. +15. Skeptical review rejected the broad memory wording: a 48 MiB policy did not + follow from the measured 6.2 MB workload, and `fs::read` could allocate an + entire oversized input before enforcing the cap. The coordinator changed + file loading to read at most 8 MiB plus one byte, added a sparse-file + regression, and limited the 64 MiB statement to the measured workload. + +## Concrete baseline and comparison + +The baseline is the shape a small TypeScript SDK would normally use: + +1. Parse JSON into a candidate object. +2. Validate the complete candidate without mutating live state. +3. Swap the active immutable reference only on success. +4. Look up a flag by key and walk its ordered rule array. +5. Hash `salt`, flag key, rule id, and a typed user attribute with one published + algorithm for percentage rules. +6. Return the selected value plus every visited rule's result. + +| Concern | Direct immutable baseline | Fold comparison | Decision | +| --- | --- | --- | --- | +| Malformed snapshot admission | Parse and validate before swap | A database transaction starts after parsing; it does not validate JSON by itself | Baseline fits directly | +| Failed reload | Candidate error leaves active `Arc` untouched | Could make storage writes atomic, but still needs an admission layer | Baseline is smaller | +| Offline evaluation | In-memory key lookup and short ordered scan | Durable reads are available but unnecessary on each decision | Baseline is faster/simpler | +| Restart-stable percentage | Explicit local FNV-1a 64 specification | Not a database concern | Baseline owns it | +| Durable derived views | Not needed by the brief | Strong Fold fit | Non-goal | +| Persistent last-known-good after process exit | Snapshot file remains an external responsibility | Fold could help if this becomes a requirement | Unresolved product boundary | + +The JavaScript reference is at +`baseline/reference-evaluator.mjs`. It provides concrete evidence that the Rust +rule and hashing semantics can match a TypeScript-style client. It is not a +claim of compatibility with an unspecified production evaluator. + +## Prototype contents + +- `src/lib.rs`: strict loader, semantic validation, immutable evaluator, + atomic reload, ordered-rule evaluation, stable bucketing, explanations, and + active-snapshot memory estimate. +- `src/main.rs`: fixture generation, NDJSON evaluation, demo, verification, + fresh-process fingerprint, large deterministic snapshot generation, and + repeated benchmark. +- `baseline/reference-evaluator.mjs`: independent TypeScript-style reference. +- `fixtures/`: 250 contexts and golden cases, normal/reordered snapshots, + good/bad reloads, and 12 malformed cases. +- `Cargo.lock`: exact cached dependency resolution. + +No BogKit path dependency is present because no BogKit component fit the +bounded problem. The package uses cached `serde` and `serde_json` only. + +## Exact evidence + +Environment: + +```console +$ rustc --version +rustc 1.95.0 (59807616e 2026-04-14) +$ cargo --version +cargo 1.95.0 (f2d3ce0bd 2026-03-21) +$ uname -a +Darwin violaceae 25.5.0 Darwin Kernel Version 25.5.0: Tue Jun 9 22:28:34 PDT 2026; root:xnu-12377.121.10~1/RELEASE_ARM64_T6041 arm64 +``` + +All commands below were run from +`/private/tmp/bogkit-2026-08-01-trial-a.AF7gv4` with build output at +`/private/tmp/offline-flag-parity-target`. + +Formatting: + +```console +$ cargo fmt --manifest-path trial-output/offline-flag-parity/Cargo.toml -- --check +# exit 0, no output +``` + +Tests: + +```console +$ CARGO_TARGET_DIR=/private/tmp/offline-flag-parity-target cargo test --offline --manifest-path trial-output/offline-flag-parity/Cargo.toml +running 4 tests +test tests::bucket_has_a_fixed_known_value ... ok +test tests::duplicate_json_keys_are_rejected ... ok +test tests::failed_reload_keeps_active_snapshot ... ok +test tests::file_reader_stops_at_the_snapshot_limit ... ok +test result: ok. 4 passed; 0 failed +# main tests: 0 passed; doc tests: 0 passed; command exit 0 +``` + +Strict lint: + +```console +$ CARGO_TARGET_DIR=/private/tmp/offline-flag-parity-target cargo clippy --offline --manifest-path trial-output/offline-flag-parity/Cargo.toml --all-targets -- -D warnings +Finished `dev` profile ... +# exit 0 +``` + +Fixture generation and Rust verification: + +```console +$ /private/tmp/offline-flag-parity-target/release/offline-flag-parity generate trial-output/offline-flag-parity/fixtures +generated snapshot, reordered snapshot, 250 NDJSON contexts/golden cases, reload fixtures, and 12 malformed snapshots in trial-output/offline-flag-parity/fixtures + +$ /private/tmp/offline-flag-parity-target/release/offline-flag-parity verify trial-output/offline-flag-parity/fixtures +verified 250 golden cases; object ordering invariant; 12 malformed snapshots rejected; good reload true -> false; bad reload preserved false +``` + +The `true -> false` line is an intentional good-reload behavior change for the +first context. The subsequent bad reload kept `demo-v2` and the same `false` +decision. + +Independent TypeScript-style comparison: + +```console +$ node trial-output/offline-flag-parity/baseline/reference-evaluator.mjs trial-output/offline-flag-parity/fixtures +TypeScript-style reference matched 250 golden cases and reordered-object decisions; known bucket=7307 +``` + +NDJSON CLI: + +```console +$ /private/tmp/offline-flag-parity-target/release/offline-flag-parity eval trial-output/offline-flag-parity/fixtures/snapshot.json trial-output/offline-flag-parity/fixtures/contexts.ndjson > /private/tmp/offline-flag-parity-eval-output.ndjson +$ wc -l /private/tmp/offline-flag-parity-eval-output.ndjson +250 /private/tmp/offline-flag-parity-eval-output.ndjson +``` + +Ten fresh-process restart check: + +```console +$ for i in 1 2 3 4 5 6 7 8 9 10; do /private/tmp/offline-flag-parity-target/release/offline-flag-parity fingerprint trial-output/offline-flag-parity/fixtures; done > /private/tmp/offline-flag-parity-final-restarts.txt +$ wc -l /private/tmp/offline-flag-parity-final-restarts.txt +10 /private/tmp/offline-flag-parity-final-restarts.txt +$ sort -u /private/tmp/offline-flag-parity-final-restarts.txt +99c04dd07bb96094 +$ sort -u /private/tmp/offline-flag-parity-final-restarts.txt | wc -l +1 +``` + +Large deterministic fixture: + +```console +$ /private/tmp/offline-flag-parity-target/release/offline-flag-parity generate-benchmark /private/tmp/offline-flag-parity-benchmark-20260801.json +generated 5000-flag/50000-rule benchmark snapshot: 6203976 bytes at /private/tmp/offline-flag-parity-benchmark-20260801.json +``` + +Each benchmark run loaded that file, held the parsed snapshot active, reloaded +the same full snapshot while sampling process RSS, then timed five separate +20,000-evaluation batches. The checksum was `324795` in all four runs. + +```text +Run 1 p95 ns: 1209, 1084, 1042, 1083, 1042 +Run 1 median p95: 1083 ns; max p95: 1209 ns +Run 1 sampled peak same-size reload RSS: 55,197,696 bytes +Run 1 current RSS after evaluation: 55,705,600 bytes + +Run 2 p95 ns: 1291, 1333, 1292, 1125, 1084 +Run 2 median p95: 1291 ns; max p95: 1333 ns +Run 2 sampled peak same-size reload RSS: 55,132,160 bytes +Run 2 current RSS after evaluation: 55,672,832 bytes + +Run 3 p95 ns: 1250, 1125, 1084, 1083, 1208 +Run 3 median p95: 1125 ns; max p95: 1250 ns +Run 3 sampled peak same-size reload RSS: 55,115,776 bytes +Run 3 current RSS after evaluation: 55,640,064 bytes + +Run 4 p95 ns: 1292, 1167, 1209, 1209, 1167 +Run 4 median p95: 1209 ns; max p95: 1292 ns +Run 4 sampled peak same-size reload RSS: 55,066,624 bytes +Run 4 current RSS after evaluation: 55,623,680 bytes +``` + +All 20 original measured p95 batches were more than two orders of magnitude +below the 250-microsecond ceiling. Four post-review runs added 20 more batches: +their maximum p95 was 1,250 ns, while sampled peak reload RSS ranged from +63,471,616 to 63,569,920 bytes. The closest post-review run left 3,538,944 +bytes of headroom under 64 MiB, materially narrower than the original process +observations. + +## Categorized findings + +### Correctness and safety + +| Finding | Severity | Confidence | Reproduction | Smallest improvement | +| --- | --- | --- | --- | --- | +| Standard JSON-to-map parsing can silently accept duplicate object keys and let a later value replace an earlier one. | High | High | `fixtures/malformed/06-duplicate-flag-key.json`; `verify` rejects it | Keep the streaming duplicate-key preflight, or use a parser with duplicate rejection built in | +| Candidate parsing must complete before any live reference changes; otherwise semantic validation failures can partially activate. | High | High | `verify` activates `demo-v2`, rejects `bad-reload.json`, then compares config id and decision | Preserve a single replace-on-success method; do not expose partial mutation APIs | +| Rule-array order is semantic, while object-key order is not. Treating both kinds of reordering alike would be a compatibility bug. | High | High for this schema | `snapshot.json` and `snapshot-reordered.json` match all 250 cases; rules remain arrays | State this distinction in the public snapshot contract and version it | +| FNV-1a 64 is stable and cross-language reproducible, but is not collision-resistant or abuse-resistant. | Medium | High | Rust unit vector and independent Node vector both return bucket `7307` | If untrusted users can choose identifiers adversarially, move to a specified keyed or cryptographic hash after a migration plan | + +### Performance and memory + +| Finding | Severity | Confidence | Reproduction | Smallest improvement | +| --- | --- | --- | --- | --- | +| A retained generic JSON tree plus typed snapshot, combined with in-process fixture generation, exceeded the memory budget (146,587,648-byte sampled RSS). | High | High for the failed implementation | Original full benchmark run | Keep duplicate checking streaming and keep tooling fixture construction out of the evaluator process | +| The fixed implementation passed 64 MiB for the measured 6.2 MB synthetic snapshot, but the closest post-review same-size reload left only about 3.5 MB headroom on this machine. | Medium | Medium-high for the measured workload | Four original and four post-review `bench SNAPSHOT` runs | Keep file reads bounded, add production telemetry, and remeasure each production data shape before relying on the limit | +| Explanation allocation is included in the roughly 1.0–1.3 microsecond p95, so it is not a current latency concern. | Low | High for this synthetic mix | Four clean benchmark runs | Retain structured explanations; optimize only if production profiles show pressure | + +### BogKit fit and developer experience + +| Finding | Severity | Confidence | Reproduction | Smallest improvement | +| --- | --- | --- | --- | --- | +| Fold's durable incremental views do not address strict JSON admission or deterministic percentage semantics, and a database is unnecessary on this read path. | Informational | High for the bounded brief | Compare public examples and Fold transaction API with the baseline table | Add a short “when not to use Fold” note and a configuration-snapshot example only if this becomes a supported use case | +| An independent package inside the repository can avoid a parent workspace edit with its own `[workspace]` table. | Low | High | Prototype manifest builds directly with `--manifest-path` | Mention this option in prototype/hackathon documentation | +| The README's normal Cargo path attempted an index refresh in a network-disabled environment even though dependencies were cached. | Low | High | First `cargo test` DNS failure; `cargo test --offline` passed | Document `--offline` for offline trials or vendor the tiny dependency set | + +## Consequential decision audit + +| Decision | Consequence | Evidence considered | Reversibility / guardrail | +| --- | --- | --- | --- | +| Use no BogKit crate | Trial concludes no-fit rather than forcing a component | Public README/examples, Fold stream transaction API, direct baseline | Fully reversible; all work is isolated and no core file changed | +| Make rule arrays ordered | Reordering targeting rules may intentionally change a decision | Existing baseline description says ordered rules | Schema contract and golden cases make the behavior explicit | +| Make object maps order-insensitive | Serializers may reorder fields/flags without changing results | Rust and JS both matched the reverse-key-order fixture | BTreeMap lookup plus 250 parity cases guard this behavior | +| Reject duplicate keys, unknown fields, and invalid semantics | Some previously tolerated snapshots would now fail closed | Risk of silent replacement/partial activation | Errors are explicit; last active config remains available in process | +| Use FNV-1a 64 with typed, null-separated fields | Stable assignment is portable, but not cryptographically strong | Fixed Rust/Node vector and ten restart fingerprints | Algorithm is documented; changing it requires an explicit migration/version | +| Include explanations on every decision | Hot path allocates a short trace | Acceptance requires rule-level explanation; benchmark includes it | Can add a borrowed/compact representation later without changing semantics | +| Keep generated fixtures in the archive | Adds 276 KiB, improves reproducibility | Golden/reorder/malformed acceptance checks | Small, synthetic, regenerable, and contains no private data | + +## Skeptical review and coordinator corrections + +The reviewer reproduced the format, test, strict-lint, fixture-verification, +reference-evaluator, restart-fingerprint, and release-benchmark paths. The +bounded no-fit decision stood: the direct immutable evaluator met this compact +workload without a BogKit component. + +The reviewer rejected two broader claims. First, one host running Rust and Node +does not establish cross-machine portability. Second, the measured 6.2 MB +snapshot did not justify a 48 MiB accepted-input policy under a 64 MiB process +budget. The coordinator changed file loading to read at most 8 MiB plus one +byte, added the sparse oversized-file regression above, and scoped the memory +result to the measured 5,000-flag/50,000-rule snapshot. Persisted last-known-good +state, a platform CI matrix, every accepted data shape, and concurrent reloads +remain unresolved. + +The coordinator then ran four fresh corrected release benchmarks. All 20 new +p95 batches remained at or below 1.250 microseconds. Peak sampled reload RSS was +63,471,616–63,569,920 bytes, so the workload still passed the 64 MiB criterion +but with only about 3.5 MB of closest observed headroom. + +No BogKit correctness defect was found. Configuration-snapshot guidance remains +a one-trial observation below the dashboard threshold. + +## Rejected alternatives + +- **Fold `KeyedStream` for flags/rules:** rejected because the candidate still + requires full JSON validation and the evaluation workload does not need + incremental materialized views or database persistence. +- **Persist every decision or explanation:** rejected as analytics, explicitly a + non-goal, and incompatible with the small offline hot path. +- **Use Rust's default map hasher for percentage buckets:** rejected because its + seed is process-specific and not a cross-language contract. +- **Canonicalize/sort rule arrays:** rejected because targeting rule order is + meaningful. Only JSON object keys are irrelevant. +- **Accept duplicate keys with “last value wins”:** rejected because malformed + delivery could silently activate a different configuration. +- **Claim the original 146 MB benchmark was only tooling overhead and ignore + it:** rejected. The loader was changed and the workload was re-measured in a + clean process. +- **Add commercial feature-flag schema compatibility:** rejected as a stated + non-goal; the prototype schema stays intentionally small. + +## Unresolved uncertainty + +- The TypeScript-style evaluator is an independent reference written for this + trial, not the unspecified production kiosk implementation. Real production + parity needs its actual snapshots, operators, coercion rules, and golden + outputs. +- The malformed corpus covers 12 important syntax/shape/semantic cases, not all + possible malformed byte strings. Fuzzing the streaming duplicate checker and + typed deserializer is the next correctness step. +- Ten restart checks ran on one arm64 macOS machine. The fixed algorithm is + specified in integer operations and independently matched Node on that host, + but cross-machine wording was rejected until a CI matrix provides evidence. +- RSS was sampled via `ps` during a same-size reload at roughly millisecond + intervals and checked again after evaluation. A shorter transient peak could + be missed. The observed result should be read as strong prototype evidence, + not a formal maximum-memory proof. +- Benchmark contexts are sequential and synthetic. There was no concurrent + evaluator access, allocator stress, thermal study, or long-duration soak. +- The evaluator preserves last-known-good state for the lifetime of the + process. Persisting a last-known-good copy across a process restart after an + external file replacement is not implemented and should be decided as a + delivery/storage responsibility before production use. +- The 8 MiB file-read cap and 5,000/50,000 semantic caps bound prototype input. + They are policy choices, not proof that every accepted shape stays below the + measured process-memory ceiling. + +## Archive hygiene + +At the pre-report check, `git status --short` showed only `?? trial-output/`. +BogKit core and existing examples remained untouched. The prototype occupied +332 KiB before this report; fixtures occupied 276 KiB. No `target` directory, +database, `.git` directory, file larger than 1 MiB, credential, or private data +was present under `trial-output/offline-flag-parity`. All contexts and user ids +are deterministic synthetic values. diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/README.md b/developer-simulation/runs/2026-08-01--offline-flag-parity/README.md new file mode 100644 index 0000000..6405c6f --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/README.md @@ -0,0 +1,31 @@ +# Offline flag parity prototype + +A local Rust library and CLI for loading a validated JSON flag snapshot, +evaluating ordered targeting rules, and retaining the last valid snapshot when +a reload fails. + +The evaluator is deliberately independent from BogKit. `EVIDENCE.md` +explains why the direct immutable-snapshot baseline fit this workload better +than a durable incremental database. + +Run from this directory, while keeping build output outside the archive: + +```console +export CARGO_TARGET_DIR=/tmp/offline-flag-parity-target +cargo run --offline --release -- generate /tmp/offline-flag-fixtures +cargo run --offline --release -- verify /tmp/offline-flag-fixtures +cargo run --offline --release -- demo /tmp/offline-flag-fixtures +cargo run --offline --release -- eval /tmp/offline-flag-fixtures/snapshot.json /tmp/offline-flag-fixtures/contexts.ndjson +cargo run --offline --release -- fingerprint /tmp/offline-flag-fixtures +node baseline/reference-evaluator.mjs /tmp/offline-flag-fixtures +cargo run --offline --release -- generate-benchmark /tmp/offline-flag-benchmark.json +cargo run --offline --release -- bench /tmp/offline-flag-benchmark.json +``` + +Snapshot rules are arrays, so their order is meaningful. JSON object order is +not meaningful. Percentage rules use FNV-1a 64 over null-separated snapshot +salt, flag key, rule id, and user attribute, then map the result into 10,000 +basis-point buckets. This algorithm is implemented locally so it does not vary +with process-randomized hash maps or restarts. Rust and the independent +JavaScript reference matched on the measured host; broader platform parity +still needs a CI matrix. diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/baseline/reference-evaluator.mjs b/developer-simulation/runs/2026-08-01--offline-flag-parity/baseline/reference-evaluator.mjs new file mode 100644 index 0000000..6b1acd4 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/baseline/reference-evaluator.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +// Dependency-free TypeScript-style reference evaluator. It is intentionally +// separate from the Rust library so the golden suite exercises cross-language +// rule and percentage semantics rather than only replaying Rust output. + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const fixtureDirectory = process.argv[2]; +if (!fixtureDirectory) { + throw new Error("usage: node baseline/reference-evaluator.mjs FIXTURE_DIR"); +} + +const snapshot = readJson("snapshot.json"); +const reordered = readJson("snapshot-reordered.json"); +const golden = readJson("golden.json"); + +for (const testCase of golden) { + const actual = evaluate(snapshot, testCase.input.flag, testCase.input.context); + const reorderedActual = evaluate( + reordered, + testCase.input.flag, + testCase.input.context, + ); + assert.deepStrictEqual(actual, testCase.expected, testCase.input.case_id); + assert.deepStrictEqual( + reorderedActual, + actual, + `${testCase.input.case_id} reordered`, + ); +} + +assert.equal(stableBucket("salt", "flag", "rule", "user-42"), 7307); +console.log( + `TypeScript-style reference matched ${golden.length} golden cases and reordered-object decisions; known bucket=7307`, +); + +function readJson(name) { + return JSON.parse(fs.readFileSync(path.join(fixtureDirectory, name), "utf8")); +} + +function evaluate(config, flagKey, context) { + const flag = config.flags[flagKey]; + if (!flag) throw new Error(`unknown flag ${JSON.stringify(flagKey)}`); + const explanation = []; + + for (const rule of flag.rules) { + let failure; + for (const condition of rule.conditions ?? []) { + if (!Object.hasOwn(context, condition.attribute)) { + failure = `missing attribute ${JSON.stringify(condition.attribute)}`; + break; + } + const actual = context[condition.attribute]; + if (!conditionMatches(actual, condition)) { + failure = `attribute ${JSON.stringify(condition.attribute)} was ${display(actual)}; condition did not match`; + break; + } + } + + if (failure) { + explanation.push({ rule_id: rule.id, matched: false, reason: failure }); + continue; + } + + if (rule.percentage) { + const attribute = rule.percentage.attribute; + if (!Object.hasOwn(context, attribute)) { + explanation.push({ + rule_id: rule.id, + matched: false, + reason: `missing percentage attribute ${JSON.stringify(attribute)}`, + }); + continue; + } + const bucket = stableBucket( + config.salt, + flagKey, + rule.id, + bucketKey(context[attribute]), + ); + if (bucket >= rule.percentage.basis_points) { + explanation.push({ + rule_id: rule.id, + matched: false, + reason: `stable bucket ${bucket} was outside 0..${rule.percentage.basis_points}`, + }); + continue; + } + explanation.push({ + rule_id: rule.id, + matched: true, + reason: `conditions matched; stable bucket ${bucket} was inside 0..${rule.percentage.basis_points}`, + }); + } else { + explanation.push({ + rule_id: rule.id, + matched: true, + reason: "all conditions matched", + }); + } + + return { + flag: flagKey, + value: rule.serve, + source: rule.id, + explanation, + }; + } + + explanation.push({ + rule_id: "default", + matched: true, + reason: "no targeting rule matched", + }); + return { + flag: flagKey, + value: flag.default, + source: "default", + explanation, + }; +} + +function conditionMatches(actual, condition) { + switch (condition.op) { + case "eq": + return actual === condition.value; + case "not_eq": + return actual !== condition.value; + case "greater_than": + return typeof actual === "number" && actual > condition.value; + default: + throw new Error(`unknown operator ${condition.op}`); + } +} + +function display(value) { + return typeof value === "string" ? JSON.stringify(value) : String(value); +} + +function bucketKey(value) { + if (typeof value === "boolean") return `b:${value}`; + if (typeof value === "number") return `n:${value}`; + return `s:${value}`; +} + +function stableBucket(salt, flag, rule, attribute) { + let hash = 0xcbf29ce484222325n; + for (const part of [salt, flag, rule, attribute]) { + for (const byte of Buffer.from(part)) { + hash ^= BigInt(byte); + hash = BigInt.asUintN(64, hash * 0x100000001b3n); + } + hash = BigInt.asUintN(64, hash * 0x100000001b3n); + } + return Number(hash % 10000n); +} diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/bad-reload.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/bad-reload.json new file mode 100644 index 0000000..8c9d7d5 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/bad-reload.json @@ -0,0 +1 @@ +{"schema_version":1,"config_id":"bad","salt":"retail-kiosk-v1","flags":{"checkout_redesign":{"default":false,"rules":[{"id":"bad-rollout","conditions":[],"serve":true,"percentage":{"attribute":"user_id","basis_points":10001}}]}}} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/contexts.ndjson b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/contexts.ndjson new file mode 100644 index 0000000..a926572 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/contexts.ndjson @@ -0,0 +1,250 @@ +{"case_id":"golden-000","flag":"checkout_redesign","context":{"accessibility_mode":true,"kiosk":false,"store_size":8000.0,"tier":"employee","user_id":"synthetic-user-000"}} +{"case_id":"golden-001","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":8257.0,"tier":"guest","user_id":"synthetic-user-001"}} +{"case_id":"golden-002","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":8514.0,"tier":"member","user_id":"synthetic-user-002"}} +{"case_id":"golden-003","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":8771.0,"tier":"member","user_id":"synthetic-user-003"}} +{"case_id":"golden-004","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":9028.0,"tier":"member","user_id":"synthetic-user-004"}} +{"case_id":"golden-005","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":9285.0,"tier":"employee","user_id":"synthetic-user-005"}} +{"case_id":"golden-006","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":9542.0,"tier":"guest","user_id":"synthetic-user-006"}} +{"case_id":"golden-007","flag":"support_prompt","context":{"accessibility_mode":true,"kiosk":true,"store_size":9799.0,"tier":"member","user_id":"synthetic-user-007"}} +{"case_id":"golden-008","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":10056.0,"tier":"member","user_id":"synthetic-user-008"}} +{"case_id":"golden-009","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":10313.0,"tier":"member","user_id":"synthetic-user-009"}} +{"case_id":"golden-010","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":10570.0,"tier":"employee","user_id":"synthetic-user-010"}} +{"case_id":"golden-011","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":10827.0,"tier":"guest","user_id":"synthetic-user-011"}} +{"case_id":"golden-012","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":11084.0,"tier":"member","user_id":"synthetic-user-012"}} +{"case_id":"golden-013","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":11341.0,"tier":"member","user_id":"synthetic-user-013"}} +{"case_id":"golden-014","flag":"receipt_style","context":{"accessibility_mode":true,"kiosk":true,"store_size":11598.0,"tier":"member","user_id":"synthetic-user-014"}} +{"case_id":"golden-015","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":11855.0,"tier":"employee","user_id":"synthetic-user-015"}} +{"case_id":"golden-016","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":12112.0,"tier":"guest","user_id":"synthetic-user-016"}} +{"case_id":"golden-017","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":12369.0,"tier":"member","user_id":"synthetic-user-017"}} +{"case_id":"golden-018","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":12626.0,"tier":"member","user_id":"synthetic-user-018"}} +{"case_id":"golden-019","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":12883.0,"tier":"member","user_id":"synthetic-user-019"}} +{"case_id":"golden-020","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":13140.0,"tier":"employee","user_id":"synthetic-user-020"}} +{"case_id":"golden-021","flag":"max_cart_items","context":{"accessibility_mode":true,"kiosk":false,"store_size":13397.0,"tier":"guest","user_id":"synthetic-user-021"}} +{"case_id":"golden-022","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":13654.0,"tier":"member","user_id":"synthetic-user-022"}} +{"case_id":"golden-023","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":13911.0,"tier":"member","user_id":"synthetic-user-023"}} +{"case_id":"golden-024","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":14168.0,"tier":"member","user_id":"synthetic-user-024"}} +{"case_id":"golden-025","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":14425.0,"tier":"employee","user_id":"synthetic-user-025"}} +{"case_id":"golden-026","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":14682.0,"tier":"guest","user_id":"synthetic-user-026"}} +{"case_id":"golden-027","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":14939.0,"tier":"member","user_id":"synthetic-user-027"}} +{"case_id":"golden-028","flag":"checkout_redesign","context":{"accessibility_mode":true,"kiosk":true,"store_size":15196.0,"tier":"member","user_id":"synthetic-user-028"}} +{"case_id":"golden-029","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":15453.0,"tier":"member","user_id":"synthetic-user-029"}} +{"case_id":"golden-030","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":15710.0,"tier":"employee","user_id":"synthetic-user-030"}} +{"case_id":"golden-031","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":15967.0,"tier":"guest","user_id":"synthetic-user-031"}} +{"case_id":"golden-032","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":16224.0,"tier":"member","user_id":"synthetic-user-032"}} +{"case_id":"golden-033","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":16481.0,"tier":"member","user_id":"synthetic-user-033"}} +{"case_id":"golden-034","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":16738.0,"tier":"member","user_id":"synthetic-user-034"}} +{"case_id":"golden-035","flag":"support_prompt","context":{"accessibility_mode":true,"kiosk":true,"store_size":16995.0,"tier":"employee","user_id":"synthetic-user-035"}} +{"case_id":"golden-036","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":17252.0,"tier":"guest","user_id":"synthetic-user-036"}} +{"case_id":"golden-037","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":17509.0,"tier":"member","user_id":"synthetic-user-037"}} +{"case_id":"golden-038","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":17766.0,"tier":"member","user_id":"synthetic-user-038"}} +{"case_id":"golden-039","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":18023.0,"tier":"member","user_id":"synthetic-user-039"}} +{"case_id":"golden-040","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":18280.0,"tier":"employee","user_id":"synthetic-user-040"}} +{"case_id":"golden-041","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":18537.0,"tier":"guest","user_id":"synthetic-user-041"}} +{"case_id":"golden-042","flag":"receipt_style","context":{"accessibility_mode":true,"kiosk":false,"store_size":18794.0,"tier":"member","user_id":"synthetic-user-042"}} +{"case_id":"golden-043","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":19051.0,"tier":"member","user_id":"synthetic-user-043"}} +{"case_id":"golden-044","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":19308.0,"tier":"member","user_id":"synthetic-user-044"}} +{"case_id":"golden-045","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":19565.0,"tier":"employee","user_id":"synthetic-user-045"}} +{"case_id":"golden-046","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":19822.0,"tier":"guest","user_id":"synthetic-user-046"}} +{"case_id":"golden-047","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":20079.0,"tier":"member","user_id":"synthetic-user-047"}} +{"case_id":"golden-048","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":20336.0,"tier":"member","user_id":"synthetic-user-048"}} +{"case_id":"golden-049","flag":"max_cart_items","context":{"accessibility_mode":true,"kiosk":true,"store_size":20593.0,"tier":"member","user_id":"synthetic-user-049"}} +{"case_id":"golden-050","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":20850.0,"tier":"employee","user_id":"synthetic-user-050"}} +{"case_id":"golden-051","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":21107.0,"tier":"guest","user_id":"synthetic-user-051"}} +{"case_id":"golden-052","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":21364.0,"tier":"member","user_id":"synthetic-user-052"}} +{"case_id":"golden-053","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":21621.0,"tier":"member","user_id":"synthetic-user-053"}} +{"case_id":"golden-054","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":21878.0,"tier":"member","user_id":"synthetic-user-054"}} +{"case_id":"golden-055","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":22135.0,"tier":"employee","user_id":"synthetic-user-055"}} +{"case_id":"golden-056","flag":"checkout_redesign","context":{"accessibility_mode":true,"kiosk":true,"store_size":22392.0,"tier":"guest","user_id":"synthetic-user-056"}} +{"case_id":"golden-057","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":22649.0,"tier":"member","user_id":"synthetic-user-057"}} +{"case_id":"golden-058","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":22906.0,"tier":"member","user_id":"synthetic-user-058"}} +{"case_id":"golden-059","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":23163.0,"tier":"member","user_id":"synthetic-user-059"}} +{"case_id":"golden-060","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":23420.0,"tier":"employee","user_id":"synthetic-user-060"}} +{"case_id":"golden-061","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":23677.0,"tier":"guest","user_id":"synthetic-user-061"}} +{"case_id":"golden-062","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":23934.0,"tier":"member","user_id":"synthetic-user-062"}} +{"case_id":"golden-063","flag":"support_prompt","context":{"accessibility_mode":true,"kiosk":false,"store_size":24191.0,"tier":"member","user_id":"synthetic-user-063"}} +{"case_id":"golden-064","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":24448.0,"tier":"member","user_id":"synthetic-user-064"}} +{"case_id":"golden-065","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":24705.0,"tier":"employee","user_id":"synthetic-user-065"}} +{"case_id":"golden-066","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":24962.0,"tier":"guest","user_id":"synthetic-user-066"}} +{"case_id":"golden-067","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":25219.0,"tier":"member","user_id":"synthetic-user-067"}} +{"case_id":"golden-068","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":25476.0,"tier":"member","user_id":"synthetic-user-068"}} +{"case_id":"golden-069","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":25733.0,"tier":"member","user_id":"synthetic-user-069"}} +{"case_id":"golden-070","flag":"receipt_style","context":{"accessibility_mode":true,"kiosk":true,"store_size":25990.0,"tier":"employee","user_id":"synthetic-user-070"}} +{"case_id":"golden-071","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":26247.0,"tier":"guest","user_id":"synthetic-user-071"}} +{"case_id":"golden-072","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":26504.0,"tier":"member","user_id":"synthetic-user-072"}} +{"case_id":"golden-073","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":26761.0,"tier":"member","user_id":"synthetic-user-073"}} +{"case_id":"golden-074","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":27018.0,"tier":"member","user_id":"synthetic-user-074"}} +{"case_id":"golden-075","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":27275.0,"tier":"employee","user_id":"synthetic-user-075"}} +{"case_id":"golden-076","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":27532.0,"tier":"guest","user_id":"synthetic-user-076"}} +{"case_id":"golden-077","flag":"max_cart_items","context":{"accessibility_mode":true,"kiosk":true,"store_size":27789.0,"tier":"member","user_id":"synthetic-user-077"}} +{"case_id":"golden-078","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":28046.0,"tier":"member","user_id":"synthetic-user-078"}} +{"case_id":"golden-079","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":28303.0,"tier":"member","user_id":"synthetic-user-079"}} +{"case_id":"golden-080","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":28560.0,"tier":"employee","user_id":"synthetic-user-080"}} +{"case_id":"golden-081","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":28817.0,"tier":"guest","user_id":"synthetic-user-081"}} +{"case_id":"golden-082","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":29074.0,"tier":"member","user_id":"synthetic-user-082"}} +{"case_id":"golden-083","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":29331.0,"tier":"member","user_id":"synthetic-user-083"}} +{"case_id":"golden-084","flag":"checkout_redesign","context":{"accessibility_mode":true,"kiosk":false,"store_size":29588.0,"tier":"member","user_id":"synthetic-user-084"}} +{"case_id":"golden-085","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":29845.0,"tier":"employee","user_id":"synthetic-user-085"}} +{"case_id":"golden-086","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":30102.0,"tier":"guest","user_id":"synthetic-user-086"}} +{"case_id":"golden-087","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":30359.0,"tier":"member","user_id":"synthetic-user-087"}} +{"case_id":"golden-088","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":30616.0,"tier":"member","user_id":"synthetic-user-088"}} +{"case_id":"golden-089","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":30873.0,"tier":"member","user_id":"synthetic-user-089"}} +{"case_id":"golden-090","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":31130.0,"tier":"employee","user_id":"synthetic-user-090"}} +{"case_id":"golden-091","flag":"support_prompt","context":{"accessibility_mode":true,"kiosk":true,"store_size":31387.0,"tier":"guest","user_id":"synthetic-user-091"}} +{"case_id":"golden-092","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":31644.0,"tier":"member","user_id":"synthetic-user-092"}} +{"case_id":"golden-093","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":31901.0,"tier":"member","user_id":"synthetic-user-093"}} +{"case_id":"golden-094","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":32158.0,"tier":"member","user_id":"synthetic-user-094"}} +{"case_id":"golden-095","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":32415.0,"tier":"employee","user_id":"synthetic-user-095"}} +{"case_id":"golden-096","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":32672.0,"tier":"guest","user_id":"synthetic-user-096"}} +{"case_id":"golden-097","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":32929.0,"tier":"member","user_id":"synthetic-user-097"}} +{"case_id":"golden-098","flag":"receipt_style","context":{"accessibility_mode":true,"kiosk":true,"store_size":8186.0,"tier":"member","user_id":"synthetic-user-098"}} +{"case_id":"golden-099","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":8443.0,"tier":"member","user_id":"synthetic-user-099"}} +{"case_id":"golden-100","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":8700.0,"tier":"employee","user_id":"synthetic-user-100"}} +{"case_id":"golden-101","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":8957.0,"tier":"guest","user_id":"synthetic-user-101"}} +{"case_id":"golden-102","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":9214.0,"tier":"member","user_id":"synthetic-user-102"}} +{"case_id":"golden-103","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":9471.0,"tier":"member","user_id":"synthetic-user-103"}} +{"case_id":"golden-104","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":9728.0,"tier":"member","user_id":"synthetic-user-104"}} +{"case_id":"golden-105","flag":"max_cart_items","context":{"accessibility_mode":true,"kiosk":false,"store_size":9985.0,"tier":"employee","user_id":"synthetic-user-105"}} +{"case_id":"golden-106","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":10242.0,"tier":"guest","user_id":"synthetic-user-106"}} +{"case_id":"golden-107","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":10499.0,"tier":"member","user_id":"synthetic-user-107"}} +{"case_id":"golden-108","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":10756.0,"tier":"member","user_id":"synthetic-user-108"}} +{"case_id":"golden-109","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":11013.0,"tier":"member","user_id":"synthetic-user-109"}} +{"case_id":"golden-110","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":11270.0,"tier":"employee","user_id":"synthetic-user-110"}} +{"case_id":"golden-111","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":11527.0,"tier":"guest","user_id":"synthetic-user-111"}} +{"case_id":"golden-112","flag":"checkout_redesign","context":{"accessibility_mode":true,"kiosk":true,"store_size":11784.0,"tier":"member","user_id":"synthetic-user-112"}} +{"case_id":"golden-113","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":12041.0,"tier":"member","user_id":"synthetic-user-113"}} +{"case_id":"golden-114","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":12298.0,"tier":"member","user_id":"synthetic-user-114"}} +{"case_id":"golden-115","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":12555.0,"tier":"employee","user_id":"synthetic-user-115"}} +{"case_id":"golden-116","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":12812.0,"tier":"guest","user_id":"synthetic-user-116"}} +{"case_id":"golden-117","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":13069.0,"tier":"member","user_id":"synthetic-user-117"}} +{"case_id":"golden-118","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":13326.0,"tier":"member","user_id":"synthetic-user-118"}} +{"case_id":"golden-119","flag":"support_prompt","context":{"accessibility_mode":true,"kiosk":true,"store_size":13583.0,"tier":"member","user_id":"synthetic-user-119"}} +{"case_id":"golden-120","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":13840.0,"tier":"employee","user_id":"synthetic-user-120"}} +{"case_id":"golden-121","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":14097.0,"tier":"guest","user_id":"synthetic-user-121"}} +{"case_id":"golden-122","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":14354.0,"tier":"member","user_id":"synthetic-user-122"}} +{"case_id":"golden-123","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":14611.0,"tier":"member","user_id":"synthetic-user-123"}} +{"case_id":"golden-124","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":14868.0,"tier":"member","user_id":"synthetic-user-124"}} +{"case_id":"golden-125","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":15125.0,"tier":"employee","user_id":"synthetic-user-125"}} +{"case_id":"golden-126","flag":"receipt_style","context":{"accessibility_mode":true,"kiosk":false,"store_size":15382.0,"tier":"guest","user_id":"synthetic-user-126"}} +{"case_id":"golden-127","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":15639.0,"tier":"member","user_id":"synthetic-user-127"}} +{"case_id":"golden-128","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":15896.0,"tier":"member","user_id":"synthetic-user-128"}} +{"case_id":"golden-129","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":16153.0,"tier":"member","user_id":"synthetic-user-129"}} +{"case_id":"golden-130","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":16410.0,"tier":"employee","user_id":"synthetic-user-130"}} +{"case_id":"golden-131","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":16667.0,"tier":"guest","user_id":"synthetic-user-131"}} +{"case_id":"golden-132","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":16924.0,"tier":"member","user_id":"synthetic-user-132"}} +{"case_id":"golden-133","flag":"max_cart_items","context":{"accessibility_mode":true,"kiosk":true,"store_size":17181.0,"tier":"member","user_id":"synthetic-user-133"}} +{"case_id":"golden-134","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":17438.0,"tier":"member","user_id":"synthetic-user-134"}} +{"case_id":"golden-135","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":17695.0,"tier":"employee","user_id":"synthetic-user-135"}} +{"case_id":"golden-136","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":17952.0,"tier":"guest","user_id":"synthetic-user-136"}} +{"case_id":"golden-137","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":18209.0,"tier":"member","user_id":"synthetic-user-137"}} +{"case_id":"golden-138","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":18466.0,"tier":"member","user_id":"synthetic-user-138"}} +{"case_id":"golden-139","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":18723.0,"tier":"member","user_id":"synthetic-user-139"}} +{"case_id":"golden-140","flag":"checkout_redesign","context":{"accessibility_mode":true,"kiosk":true,"store_size":18980.0,"tier":"employee","user_id":"synthetic-user-140"}} +{"case_id":"golden-141","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":19237.0,"tier":"guest","user_id":"synthetic-user-141"}} +{"case_id":"golden-142","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":19494.0,"tier":"member","user_id":"synthetic-user-142"}} +{"case_id":"golden-143","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":19751.0,"tier":"member","user_id":"synthetic-user-143"}} +{"case_id":"golden-144","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":20008.0,"tier":"member","user_id":"synthetic-user-144"}} +{"case_id":"golden-145","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":20265.0,"tier":"employee","user_id":"synthetic-user-145"}} +{"case_id":"golden-146","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":20522.0,"tier":"guest","user_id":"synthetic-user-146"}} +{"case_id":"golden-147","flag":"support_prompt","context":{"accessibility_mode":true,"kiosk":false,"store_size":20779.0,"tier":"member","user_id":"synthetic-user-147"}} +{"case_id":"golden-148","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":21036.0,"tier":"member","user_id":"synthetic-user-148"}} +{"case_id":"golden-149","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":21293.0,"tier":"member","user_id":"synthetic-user-149"}} +{"case_id":"golden-150","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":21550.0,"tier":"employee","user_id":"synthetic-user-150"}} +{"case_id":"golden-151","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":21807.0,"tier":"guest","user_id":"synthetic-user-151"}} +{"case_id":"golden-152","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":22064.0,"tier":"member","user_id":"synthetic-user-152"}} +{"case_id":"golden-153","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":22321.0,"tier":"member","user_id":"synthetic-user-153"}} +{"case_id":"golden-154","flag":"receipt_style","context":{"accessibility_mode":true,"kiosk":true,"store_size":22578.0,"tier":"member","user_id":"synthetic-user-154"}} +{"case_id":"golden-155","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":22835.0,"tier":"employee","user_id":"synthetic-user-155"}} +{"case_id":"golden-156","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":23092.0,"tier":"guest","user_id":"synthetic-user-156"}} +{"case_id":"golden-157","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":23349.0,"tier":"member","user_id":"synthetic-user-157"}} +{"case_id":"golden-158","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":23606.0,"tier":"member","user_id":"synthetic-user-158"}} +{"case_id":"golden-159","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":23863.0,"tier":"member","user_id":"synthetic-user-159"}} +{"case_id":"golden-160","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":24120.0,"tier":"employee","user_id":"synthetic-user-160"}} +{"case_id":"golden-161","flag":"max_cart_items","context":{"accessibility_mode":true,"kiosk":true,"store_size":24377.0,"tier":"guest","user_id":"synthetic-user-161"}} +{"case_id":"golden-162","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":24634.0,"tier":"member","user_id":"synthetic-user-162"}} +{"case_id":"golden-163","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":24891.0,"tier":"member","user_id":"synthetic-user-163"}} +{"case_id":"golden-164","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":25148.0,"tier":"member","user_id":"synthetic-user-164"}} +{"case_id":"golden-165","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":25405.0,"tier":"employee","user_id":"synthetic-user-165"}} +{"case_id":"golden-166","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":25662.0,"tier":"guest","user_id":"synthetic-user-166"}} +{"case_id":"golden-167","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":25919.0,"tier":"member","user_id":"synthetic-user-167"}} +{"case_id":"golden-168","flag":"checkout_redesign","context":{"accessibility_mode":true,"kiosk":false,"store_size":26176.0,"tier":"member","user_id":"synthetic-user-168"}} +{"case_id":"golden-169","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":26433.0,"tier":"member","user_id":"synthetic-user-169"}} +{"case_id":"golden-170","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":26690.0,"tier":"employee","user_id":"synthetic-user-170"}} +{"case_id":"golden-171","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":26947.0,"tier":"guest","user_id":"synthetic-user-171"}} +{"case_id":"golden-172","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":27204.0,"tier":"member","user_id":"synthetic-user-172"}} +{"case_id":"golden-173","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":27461.0,"tier":"member","user_id":"synthetic-user-173"}} +{"case_id":"golden-174","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":27718.0,"tier":"member","user_id":"synthetic-user-174"}} +{"case_id":"golden-175","flag":"support_prompt","context":{"accessibility_mode":true,"kiosk":true,"store_size":27975.0,"tier":"employee","user_id":"synthetic-user-175"}} +{"case_id":"golden-176","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":28232.0,"tier":"guest","user_id":"synthetic-user-176"}} +{"case_id":"golden-177","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":28489.0,"tier":"member","user_id":"synthetic-user-177"}} +{"case_id":"golden-178","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":28746.0,"tier":"member","user_id":"synthetic-user-178"}} +{"case_id":"golden-179","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":29003.0,"tier":"member","user_id":"synthetic-user-179"}} +{"case_id":"golden-180","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":29260.0,"tier":"employee","user_id":"synthetic-user-180"}} +{"case_id":"golden-181","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":29517.0,"tier":"guest","user_id":"synthetic-user-181"}} +{"case_id":"golden-182","flag":"receipt_style","context":{"accessibility_mode":true,"kiosk":true,"store_size":29774.0,"tier":"member","user_id":"synthetic-user-182"}} +{"case_id":"golden-183","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":30031.0,"tier":"member","user_id":"synthetic-user-183"}} +{"case_id":"golden-184","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":30288.0,"tier":"member","user_id":"synthetic-user-184"}} +{"case_id":"golden-185","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":30545.0,"tier":"employee","user_id":"synthetic-user-185"}} +{"case_id":"golden-186","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":30802.0,"tier":"guest","user_id":"synthetic-user-186"}} +{"case_id":"golden-187","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":31059.0,"tier":"member","user_id":"synthetic-user-187"}} +{"case_id":"golden-188","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":31316.0,"tier":"member","user_id":"synthetic-user-188"}} +{"case_id":"golden-189","flag":"max_cart_items","context":{"accessibility_mode":true,"kiosk":false,"store_size":31573.0,"tier":"member","user_id":"synthetic-user-189"}} +{"case_id":"golden-190","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":31830.0,"tier":"employee","user_id":"synthetic-user-190"}} +{"case_id":"golden-191","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":32087.0,"tier":"guest","user_id":"synthetic-user-191"}} +{"case_id":"golden-192","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":32344.0,"tier":"member","user_id":"synthetic-user-192"}} +{"case_id":"golden-193","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":32601.0,"tier":"member","user_id":"synthetic-user-193"}} +{"case_id":"golden-194","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":32858.0,"tier":"member","user_id":"synthetic-user-194"}} +{"case_id":"golden-195","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":8115.0,"tier":"employee","user_id":"synthetic-user-195"}} +{"case_id":"golden-196","flag":"checkout_redesign","context":{"accessibility_mode":true,"kiosk":true,"store_size":8372.0,"tier":"guest","user_id":"synthetic-user-196"}} +{"case_id":"golden-197","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":8629.0,"tier":"member","user_id":"synthetic-user-197"}} +{"case_id":"golden-198","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":8886.0,"tier":"member","user_id":"synthetic-user-198"}} +{"case_id":"golden-199","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":9143.0,"tier":"member","user_id":"synthetic-user-199"}} +{"case_id":"golden-200","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":9400.0,"tier":"employee","user_id":"synthetic-user-200"}} +{"case_id":"golden-201","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":9657.0,"tier":"guest","user_id":"synthetic-user-201"}} +{"case_id":"golden-202","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":9914.0,"tier":"member","user_id":"synthetic-user-202"}} +{"case_id":"golden-203","flag":"support_prompt","context":{"accessibility_mode":true,"kiosk":true,"store_size":10171.0,"tier":"member","user_id":"synthetic-user-203"}} +{"case_id":"golden-204","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":10428.0,"tier":"member","user_id":"synthetic-user-204"}} +{"case_id":"golden-205","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":10685.0,"tier":"employee","user_id":"synthetic-user-205"}} +{"case_id":"golden-206","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":10942.0,"tier":"guest","user_id":"synthetic-user-206"}} +{"case_id":"golden-207","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":11199.0,"tier":"member","user_id":"synthetic-user-207"}} +{"case_id":"golden-208","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":11456.0,"tier":"member","user_id":"synthetic-user-208"}} +{"case_id":"golden-209","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":11713.0,"tier":"member","user_id":"synthetic-user-209"}} +{"case_id":"golden-210","flag":"receipt_style","context":{"accessibility_mode":true,"kiosk":false,"store_size":11970.0,"tier":"employee","user_id":"synthetic-user-210"}} +{"case_id":"golden-211","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":12227.0,"tier":"guest","user_id":"synthetic-user-211"}} +{"case_id":"golden-212","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":12484.0,"tier":"member","user_id":"synthetic-user-212"}} +{"case_id":"golden-213","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":12741.0,"tier":"member","user_id":"synthetic-user-213"}} +{"case_id":"golden-214","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":12998.0,"tier":"member","user_id":"synthetic-user-214"}} +{"case_id":"golden-215","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":13255.0,"tier":"employee","user_id":"synthetic-user-215"}} +{"case_id":"golden-216","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":13512.0,"tier":"guest","user_id":"synthetic-user-216"}} +{"case_id":"golden-217","flag":"max_cart_items","context":{"accessibility_mode":true,"kiosk":true,"store_size":13769.0,"tier":"member","user_id":"synthetic-user-217"}} +{"case_id":"golden-218","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":14026.0,"tier":"member","user_id":"synthetic-user-218"}} +{"case_id":"golden-219","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":14283.0,"tier":"member","user_id":"synthetic-user-219"}} +{"case_id":"golden-220","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":14540.0,"tier":"employee","user_id":"synthetic-user-220"}} +{"case_id":"golden-221","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":14797.0,"tier":"guest","user_id":"synthetic-user-221"}} +{"case_id":"golden-222","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":15054.0,"tier":"member","user_id":"synthetic-user-222"}} +{"case_id":"golden-223","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":15311.0,"tier":"member","user_id":"synthetic-user-223"}} +{"case_id":"golden-224","flag":"checkout_redesign","context":{"accessibility_mode":true,"kiosk":true,"store_size":15568.0,"tier":"member","user_id":"synthetic-user-224"}} +{"case_id":"golden-225","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":15825.0,"tier":"employee","user_id":"synthetic-user-225"}} +{"case_id":"golden-226","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":16082.0,"tier":"guest","user_id":"synthetic-user-226"}} +{"case_id":"golden-227","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":16339.0,"tier":"member","user_id":"synthetic-user-227"}} +{"case_id":"golden-228","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":16596.0,"tier":"member","user_id":"synthetic-user-228"}} +{"case_id":"golden-229","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":16853.0,"tier":"member","user_id":"synthetic-user-229"}} +{"case_id":"golden-230","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":17110.0,"tier":"employee","user_id":"synthetic-user-230"}} +{"case_id":"golden-231","flag":"support_prompt","context":{"accessibility_mode":true,"kiosk":false,"store_size":17367.0,"tier":"guest","user_id":"synthetic-user-231"}} +{"case_id":"golden-232","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":17624.0,"tier":"member","user_id":"synthetic-user-232"}} +{"case_id":"golden-233","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":17881.0,"tier":"member","user_id":"synthetic-user-233"}} +{"case_id":"golden-234","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":18138.0,"tier":"member","user_id":"synthetic-user-234"}} +{"case_id":"golden-235","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":18395.0,"tier":"employee","user_id":"synthetic-user-235"}} +{"case_id":"golden-236","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":18652.0,"tier":"guest","user_id":"synthetic-user-236"}} +{"case_id":"golden-237","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":18909.0,"tier":"member","user_id":"synthetic-user-237"}} +{"case_id":"golden-238","flag":"receipt_style","context":{"accessibility_mode":true,"kiosk":true,"store_size":19166.0,"tier":"member","user_id":"synthetic-user-238"}} +{"case_id":"golden-239","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":19423.0,"tier":"member","user_id":"synthetic-user-239"}} +{"case_id":"golden-240","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":false,"store_size":19680.0,"tier":"employee","user_id":"synthetic-user-240"}} +{"case_id":"golden-241","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":true,"store_size":19937.0,"tier":"guest","user_id":"synthetic-user-241"}} +{"case_id":"golden-242","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":true,"store_size":20194.0,"tier":"member","user_id":"synthetic-user-242"}} +{"case_id":"golden-243","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":false,"store_size":20451.0,"tier":"member","user_id":"synthetic-user-243"}} +{"case_id":"golden-244","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":20708.0,"tier":"member","user_id":"synthetic-user-244"}} +{"case_id":"golden-245","flag":"max_cart_items","context":{"accessibility_mode":true,"kiosk":true,"store_size":20965.0,"tier":"employee","user_id":"synthetic-user-245"}} +{"case_id":"golden-246","flag":"receipt_style","context":{"accessibility_mode":false,"kiosk":false,"store_size":21222.0,"tier":"guest","user_id":"synthetic-user-246"}} +{"case_id":"golden-247","flag":"support_prompt","context":{"accessibility_mode":false,"kiosk":true,"store_size":21479.0,"tier":"member","user_id":"synthetic-user-247"}} +{"case_id":"golden-248","flag":"checkout_redesign","context":{"accessibility_mode":false,"kiosk":true,"store_size":21736.0,"tier":"member","user_id":"synthetic-user-248"}} +{"case_id":"golden-249","flag":"max_cart_items","context":{"accessibility_mode":false,"kiosk":false,"store_size":21993.0,"tier":"member","user_id":"synthetic-user-249"}} diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/golden.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/golden.json new file mode 100644 index 0000000..51055f0 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/golden.json @@ -0,0 +1,7212 @@ +[ + { + "input": { + "case_id": "golden-000", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": true, + "kiosk": false, + "store_size": 8000.0, + "tier": "employee", + "user_id": "synthetic-user-000" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "employees-first", + "explanation": [ + { + "rule_id": "employees-first", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-001", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 8257.0, + "tier": "guest", + "user_id": "synthetic-user-001" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 8257; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-002", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 8514.0, + "tier": "member", + "user_id": "synthetic-user-002" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-003", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 8771.0, + "tier": "member", + "user_id": "synthetic-user-003" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-004", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 9028.0, + "tier": "member", + "user_id": "synthetic-user-004" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 7272 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-005", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 9285.0, + "tier": "employee", + "user_id": "synthetic-user-005" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 9285; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-006", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 9542.0, + "tier": "guest", + "user_id": "synthetic-user-006" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-007", + "flag": "support_prompt", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 9799.0, + "tier": "member", + "user_id": "synthetic-user-007" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-008", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 10056.0, + "tier": "member", + "user_id": "synthetic-user-008" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 9892 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-009", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 10313.0, + "tier": "member", + "user_id": "synthetic-user-009" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 10313; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-010", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 10570.0, + "tier": "employee", + "user_id": "synthetic-user-010" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-011", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 10827.0, + "tier": "guest", + "user_id": "synthetic-user-011" + } + }, + "expected": { + "flag": "support_prompt", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "non-guest", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-012", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 11084.0, + "tier": "member", + "user_id": "synthetic-user-012" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-013", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 11341.0, + "tier": "member", + "user_id": "synthetic-user-013" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 11341; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-014", + "flag": "receipt_style", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 11598.0, + "tier": "member", + "user_id": "synthetic-user-014" + } + }, + "expected": { + "flag": "receipt_style", + "value": "large-print", + "source": "accessible-store", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-015", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 11855.0, + "tier": "employee", + "user_id": "synthetic-user-015" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-016", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 12112.0, + "tier": "guest", + "user_id": "synthetic-user-016" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 5137 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-017", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 12369.0, + "tier": "member", + "user_id": "synthetic-user-017" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 12369; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-018", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 12626.0, + "tier": "member", + "user_id": "synthetic-user-018" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-019", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 12883.0, + "tier": "member", + "user_id": "synthetic-user-019" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-020", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 13140.0, + "tier": "employee", + "user_id": "synthetic-user-020" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "employees-first", + "explanation": [ + { + "rule_id": "employees-first", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-021", + "flag": "max_cart_items", + "context": { + "accessibility_mode": true, + "kiosk": false, + "store_size": 13397.0, + "tier": "guest", + "user_id": "synthetic-user-021" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 13397; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-022", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 13654.0, + "tier": "member", + "user_id": "synthetic-user-022" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-023", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 13911.0, + "tier": "member", + "user_id": "synthetic-user-023" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-024", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 14168.0, + "tier": "member", + "user_id": "synthetic-user-024" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-025", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 14425.0, + "tier": "employee", + "user_id": "synthetic-user-025" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 14425; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-026", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 14682.0, + "tier": "guest", + "user_id": "synthetic-user-026" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-027", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 14939.0, + "tier": "member", + "user_id": "synthetic-user-027" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-028", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 15196.0, + "tier": "member", + "user_id": "synthetic-user-028" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 9642 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-029", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 15453.0, + "tier": "member", + "user_id": "synthetic-user-029" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 15453; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-030", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 15710.0, + "tier": "employee", + "user_id": "synthetic-user-030" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-031", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 15967.0, + "tier": "guest", + "user_id": "synthetic-user-031" + } + }, + "expected": { + "flag": "support_prompt", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "non-guest", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-032", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 16224.0, + "tier": "member", + "user_id": "synthetic-user-032" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 6287 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-033", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 16481.0, + "tier": "member", + "user_id": "synthetic-user-033" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 16481; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-034", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 16738.0, + "tier": "member", + "user_id": "synthetic-user-034" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-035", + "flag": "support_prompt", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 16995.0, + "tier": "employee", + "user_id": "synthetic-user-035" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-036", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 17252.0, + "tier": "guest", + "user_id": "synthetic-user-036" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-037", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 17509.0, + "tier": "member", + "user_id": "synthetic-user-037" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 17509; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-038", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 17766.0, + "tier": "member", + "user_id": "synthetic-user-038" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-039", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 18023.0, + "tier": "member", + "user_id": "synthetic-user-039" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-040", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 18280.0, + "tier": "employee", + "user_id": "synthetic-user-040" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "employees-first", + "explanation": [ + { + "rule_id": "employees-first", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-041", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 18537.0, + "tier": "guest", + "user_id": "synthetic-user-041" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 18537; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-042", + "flag": "receipt_style", + "context": { + "accessibility_mode": true, + "kiosk": false, + "store_size": 18794.0, + "tier": "member", + "user_id": "synthetic-user-042" + } + }, + "expected": { + "flag": "receipt_style", + "value": "large-print", + "source": "accessible-store", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-043", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 19051.0, + "tier": "member", + "user_id": "synthetic-user-043" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-044", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 19308.0, + "tier": "member", + "user_id": "synthetic-user-044" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 7332 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-045", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 19565.0, + "tier": "employee", + "user_id": "synthetic-user-045" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 19565; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-046", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 19822.0, + "tier": "guest", + "user_id": "synthetic-user-046" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-047", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 20079.0, + "tier": "member", + "user_id": "synthetic-user-047" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-048", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 20336.0, + "tier": "member", + "user_id": "synthetic-user-048" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-049", + "flag": "max_cart_items", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 20593.0, + "tier": "member", + "user_id": "synthetic-user-049" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-050", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 20850.0, + "tier": "employee", + "user_id": "synthetic-user-050" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-051", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 21107.0, + "tier": "guest", + "user_id": "synthetic-user-051" + } + }, + "expected": { + "flag": "support_prompt", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "non-guest", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-052", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 21364.0, + "tier": "member", + "user_id": "synthetic-user-052" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 7817 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-053", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 21621.0, + "tier": "member", + "user_id": "synthetic-user-053" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-054", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 21878.0, + "tier": "member", + "user_id": "synthetic-user-054" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-055", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 22135.0, + "tier": "employee", + "user_id": "synthetic-user-055" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-056", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 22392.0, + "tier": "guest", + "user_id": "synthetic-user-056" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 5197 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-057", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 22649.0, + "tier": "member", + "user_id": "synthetic-user-057" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-058", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 22906.0, + "tier": "member", + "user_id": "synthetic-user-058" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-059", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 23163.0, + "tier": "member", + "user_id": "synthetic-user-059" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-060", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 23420.0, + "tier": "employee", + "user_id": "synthetic-user-060" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "employees-first", + "explanation": [ + { + "rule_id": "employees-first", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-061", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 23677.0, + "tier": "guest", + "user_id": "synthetic-user-061" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-062", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 23934.0, + "tier": "member", + "user_id": "synthetic-user-062" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-063", + "flag": "support_prompt", + "context": { + "accessibility_mode": true, + "kiosk": false, + "store_size": 24191.0, + "tier": "member", + "user_id": "synthetic-user-063" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-064", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 24448.0, + "tier": "member", + "user_id": "synthetic-user-064" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "kiosk-rollout", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": true, + "reason": "conditions matched; stable bucket 1842 was inside 0..3500" + } + ] + } + }, + { + "input": { + "case_id": "golden-065", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 24705.0, + "tier": "employee", + "user_id": "synthetic-user-065" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-066", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 24962.0, + "tier": "guest", + "user_id": "synthetic-user-066" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-067", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 25219.0, + "tier": "member", + "user_id": "synthetic-user-067" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-068", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 25476.0, + "tier": "member", + "user_id": "synthetic-user-068" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 3982 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-069", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 25733.0, + "tier": "member", + "user_id": "synthetic-user-069" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-070", + "flag": "receipt_style", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 25990.0, + "tier": "employee", + "user_id": "synthetic-user-070" + } + }, + "expected": { + "flag": "receipt_style", + "value": "large-print", + "source": "accessible-store", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-071", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 26247.0, + "tier": "guest", + "user_id": "synthetic-user-071" + } + }, + "expected": { + "flag": "support_prompt", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "non-guest", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-072", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 26504.0, + "tier": "member", + "user_id": "synthetic-user-072" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-073", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 26761.0, + "tier": "member", + "user_id": "synthetic-user-073" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-074", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 27018.0, + "tier": "member", + "user_id": "synthetic-user-074" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-075", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 27275.0, + "tier": "employee", + "user_id": "synthetic-user-075" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-076", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 27532.0, + "tier": "guest", + "user_id": "synthetic-user-076" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 8007 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-077", + "flag": "max_cart_items", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 27789.0, + "tier": "member", + "user_id": "synthetic-user-077" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-078", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 28046.0, + "tier": "member", + "user_id": "synthetic-user-078" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-079", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 28303.0, + "tier": "member", + "user_id": "synthetic-user-079" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-080", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 28560.0, + "tier": "employee", + "user_id": "synthetic-user-080" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "employees-first", + "explanation": [ + { + "rule_id": "employees-first", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-081", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 28817.0, + "tier": "guest", + "user_id": "synthetic-user-081" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-082", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 29074.0, + "tier": "member", + "user_id": "synthetic-user-082" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-083", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 29331.0, + "tier": "member", + "user_id": "synthetic-user-083" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-084", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": true, + "kiosk": false, + "store_size": 29588.0, + "tier": "member", + "user_id": "synthetic-user-084" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-085", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 29845.0, + "tier": "employee", + "user_id": "synthetic-user-085" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-086", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 30102.0, + "tier": "guest", + "user_id": "synthetic-user-086" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-087", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 30359.0, + "tier": "member", + "user_id": "synthetic-user-087" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-088", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 30616.0, + "tier": "member", + "user_id": "synthetic-user-088" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "kiosk-rollout", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": true, + "reason": "conditions matched; stable bucket 732 was inside 0..3500" + } + ] + } + }, + { + "input": { + "case_id": "golden-089", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 30873.0, + "tier": "member", + "user_id": "synthetic-user-089" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-090", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 31130.0, + "tier": "employee", + "user_id": "synthetic-user-090" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-091", + "flag": "support_prompt", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 31387.0, + "tier": "guest", + "user_id": "synthetic-user-091" + } + }, + "expected": { + "flag": "support_prompt", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "non-guest", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-092", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 31644.0, + "tier": "member", + "user_id": "synthetic-user-092" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 9997 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-093", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 31901.0, + "tier": "member", + "user_id": "synthetic-user-093" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-094", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 32158.0, + "tier": "member", + "user_id": "synthetic-user-094" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-095", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 32415.0, + "tier": "employee", + "user_id": "synthetic-user-095" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-096", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 32672.0, + "tier": "guest", + "user_id": "synthetic-user-096" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-097", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 32929.0, + "tier": "member", + "user_id": "synthetic-user-097" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-098", + "flag": "receipt_style", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 8186.0, + "tier": "member", + "user_id": "synthetic-user-098" + } + }, + "expected": { + "flag": "receipt_style", + "value": "large-print", + "source": "accessible-store", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-099", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 8443.0, + "tier": "member", + "user_id": "synthetic-user-099" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-100", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 8700.0, + "tier": "employee", + "user_id": "synthetic-user-100" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "employees-first", + "explanation": [ + { + "rule_id": "employees-first", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-101", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 8957.0, + "tier": "guest", + "user_id": "synthetic-user-101" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 8957; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-102", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 9214.0, + "tier": "member", + "user_id": "synthetic-user-102" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-103", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 9471.0, + "tier": "member", + "user_id": "synthetic-user-103" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-104", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 9728.0, + "tier": "member", + "user_id": "synthetic-user-104" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 6521 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-105", + "flag": "max_cart_items", + "context": { + "accessibility_mode": true, + "kiosk": false, + "store_size": 9985.0, + "tier": "employee", + "user_id": "synthetic-user-105" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 9985; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-106", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 10242.0, + "tier": "guest", + "user_id": "synthetic-user-106" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-107", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 10499.0, + "tier": "member", + "user_id": "synthetic-user-107" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-108", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 10756.0, + "tier": "member", + "user_id": "synthetic-user-108" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-109", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 11013.0, + "tier": "member", + "user_id": "synthetic-user-109" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 11013; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-110", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 11270.0, + "tier": "employee", + "user_id": "synthetic-user-110" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-111", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 11527.0, + "tier": "guest", + "user_id": "synthetic-user-111" + } + }, + "expected": { + "flag": "support_prompt", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "non-guest", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-112", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 11784.0, + "tier": "member", + "user_id": "synthetic-user-112" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 4112 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-113", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 12041.0, + "tier": "member", + "user_id": "synthetic-user-113" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 12041; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-114", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 12298.0, + "tier": "member", + "user_id": "synthetic-user-114" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-115", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 12555.0, + "tier": "employee", + "user_id": "synthetic-user-115" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-116", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 12812.0, + "tier": "guest", + "user_id": "synthetic-user-116" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "kiosk-rollout", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": true, + "reason": "conditions matched; stable bucket 1492 was inside 0..3500" + } + ] + } + }, + { + "input": { + "case_id": "golden-117", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 13069.0, + "tier": "member", + "user_id": "synthetic-user-117" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 13069; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-118", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 13326.0, + "tier": "member", + "user_id": "synthetic-user-118" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-119", + "flag": "support_prompt", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 13583.0, + "tier": "member", + "user_id": "synthetic-user-119" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-120", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 13840.0, + "tier": "employee", + "user_id": "synthetic-user-120" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "employees-first", + "explanation": [ + { + "rule_id": "employees-first", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-121", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 14097.0, + "tier": "guest", + "user_id": "synthetic-user-121" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 14097; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-122", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 14354.0, + "tier": "member", + "user_id": "synthetic-user-122" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-123", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 14611.0, + "tier": "member", + "user_id": "synthetic-user-123" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-124", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 14868.0, + "tier": "member", + "user_id": "synthetic-user-124" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "kiosk-rollout", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": true, + "reason": "conditions matched; stable bucket 1747 was inside 0..3500" + } + ] + } + }, + { + "input": { + "case_id": "golden-125", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 15125.0, + "tier": "employee", + "user_id": "synthetic-user-125" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 15125; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-126", + "flag": "receipt_style", + "context": { + "accessibility_mode": true, + "kiosk": false, + "store_size": 15382.0, + "tier": "guest", + "user_id": "synthetic-user-126" + } + }, + "expected": { + "flag": "receipt_style", + "value": "large-print", + "source": "accessible-store", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-127", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 15639.0, + "tier": "member", + "user_id": "synthetic-user-127" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-128", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 15896.0, + "tier": "member", + "user_id": "synthetic-user-128" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 4367 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-129", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 16153.0, + "tier": "member", + "user_id": "synthetic-user-129" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 16153; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-130", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 16410.0, + "tier": "employee", + "user_id": "synthetic-user-130" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-131", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 16667.0, + "tier": "guest", + "user_id": "synthetic-user-131" + } + }, + "expected": { + "flag": "support_prompt", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "non-guest", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-132", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 16924.0, + "tier": "member", + "user_id": "synthetic-user-132" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-133", + "flag": "max_cart_items", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 17181.0, + "tier": "member", + "user_id": "synthetic-user-133" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 17181; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-134", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 17438.0, + "tier": "member", + "user_id": "synthetic-user-134" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-135", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 17695.0, + "tier": "employee", + "user_id": "synthetic-user-135" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-136", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 17952.0, + "tier": "guest", + "user_id": "synthetic-user-136" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 7722 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-137", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 18209.0, + "tier": "member", + "user_id": "synthetic-user-137" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 18209; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-138", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 18466.0, + "tier": "member", + "user_id": "synthetic-user-138" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-139", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 18723.0, + "tier": "member", + "user_id": "synthetic-user-139" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-140", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 18980.0, + "tier": "employee", + "user_id": "synthetic-user-140" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "employees-first", + "explanation": [ + { + "rule_id": "employees-first", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-141", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 19237.0, + "tier": "guest", + "user_id": "synthetic-user-141" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 19237; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-142", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 19494.0, + "tier": "member", + "user_id": "synthetic-user-142" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-143", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 19751.0, + "tier": "member", + "user_id": "synthetic-user-143" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-144", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 20008.0, + "tier": "member", + "user_id": "synthetic-user-144" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-145", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 20265.0, + "tier": "employee", + "user_id": "synthetic-user-145" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-146", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 20522.0, + "tier": "guest", + "user_id": "synthetic-user-146" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-147", + "flag": "support_prompt", + "context": { + "accessibility_mode": true, + "kiosk": false, + "store_size": 20779.0, + "tier": "member", + "user_id": "synthetic-user-147" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-148", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 21036.0, + "tier": "member", + "user_id": "synthetic-user-148" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 5097 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-149", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 21293.0, + "tier": "member", + "user_id": "synthetic-user-149" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-150", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 21550.0, + "tier": "employee", + "user_id": "synthetic-user-150" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-151", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 21807.0, + "tier": "guest", + "user_id": "synthetic-user-151" + } + }, + "expected": { + "flag": "support_prompt", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "non-guest", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-152", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 22064.0, + "tier": "member", + "user_id": "synthetic-user-152" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 8452 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-153", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 22321.0, + "tier": "member", + "user_id": "synthetic-user-153" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-154", + "flag": "receipt_style", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 22578.0, + "tier": "member", + "user_id": "synthetic-user-154" + } + }, + "expected": { + "flag": "receipt_style", + "value": "large-print", + "source": "accessible-store", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-155", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 22835.0, + "tier": "employee", + "user_id": "synthetic-user-155" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-156", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 23092.0, + "tier": "guest", + "user_id": "synthetic-user-156" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-157", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 23349.0, + "tier": "member", + "user_id": "synthetic-user-157" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-158", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 23606.0, + "tier": "member", + "user_id": "synthetic-user-158" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-159", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 23863.0, + "tier": "member", + "user_id": "synthetic-user-159" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-160", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 24120.0, + "tier": "employee", + "user_id": "synthetic-user-160" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "employees-first", + "explanation": [ + { + "rule_id": "employees-first", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-161", + "flag": "max_cart_items", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 24377.0, + "tier": "guest", + "user_id": "synthetic-user-161" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-162", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 24634.0, + "tier": "member", + "user_id": "synthetic-user-162" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-163", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 24891.0, + "tier": "member", + "user_id": "synthetic-user-163" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-164", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 25148.0, + "tier": "member", + "user_id": "synthetic-user-164" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "kiosk-rollout", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": true, + "reason": "conditions matched; stable bucket 1807 was inside 0..3500" + } + ] + } + }, + { + "input": { + "case_id": "golden-165", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 25405.0, + "tier": "employee", + "user_id": "synthetic-user-165" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-166", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 25662.0, + "tier": "guest", + "user_id": "synthetic-user-166" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-167", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 25919.0, + "tier": "member", + "user_id": "synthetic-user-167" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-168", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": true, + "kiosk": false, + "store_size": 26176.0, + "tier": "member", + "user_id": "synthetic-user-168" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-169", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 26433.0, + "tier": "member", + "user_id": "synthetic-user-169" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-170", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 26690.0, + "tier": "employee", + "user_id": "synthetic-user-170" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-171", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 26947.0, + "tier": "guest", + "user_id": "synthetic-user-171" + } + }, + "expected": { + "flag": "support_prompt", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "non-guest", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-172", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 27204.0, + "tier": "member", + "user_id": "synthetic-user-172" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "kiosk-rollout", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": true, + "reason": "conditions matched; stable bucket 402 was inside 0..3500" + } + ] + } + }, + { + "input": { + "case_id": "golden-173", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 27461.0, + "tier": "member", + "user_id": "synthetic-user-173" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-174", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 27718.0, + "tier": "member", + "user_id": "synthetic-user-174" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-175", + "flag": "support_prompt", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 27975.0, + "tier": "employee", + "user_id": "synthetic-user-175" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-176", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 28232.0, + "tier": "guest", + "user_id": "synthetic-user-176" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 7782 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-177", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 28489.0, + "tier": "member", + "user_id": "synthetic-user-177" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-178", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 28746.0, + "tier": "member", + "user_id": "synthetic-user-178" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-179", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 29003.0, + "tier": "member", + "user_id": "synthetic-user-179" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-180", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 29260.0, + "tier": "employee", + "user_id": "synthetic-user-180" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "employees-first", + "explanation": [ + { + "rule_id": "employees-first", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-181", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 29517.0, + "tier": "guest", + "user_id": "synthetic-user-181" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-182", + "flag": "receipt_style", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 29774.0, + "tier": "member", + "user_id": "synthetic-user-182" + } + }, + "expected": { + "flag": "receipt_style", + "value": "large-print", + "source": "accessible-store", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-183", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 30031.0, + "tier": "member", + "user_id": "synthetic-user-183" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-184", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 30288.0, + "tier": "member", + "user_id": "synthetic-user-184" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 6881 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-185", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 30545.0, + "tier": "employee", + "user_id": "synthetic-user-185" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-186", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 30802.0, + "tier": "guest", + "user_id": "synthetic-user-186" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-187", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 31059.0, + "tier": "member", + "user_id": "synthetic-user-187" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-188", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 31316.0, + "tier": "member", + "user_id": "synthetic-user-188" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 9501 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-189", + "flag": "max_cart_items", + "context": { + "accessibility_mode": true, + "kiosk": false, + "store_size": 31573.0, + "tier": "member", + "user_id": "synthetic-user-189" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-190", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 31830.0, + "tier": "employee", + "user_id": "synthetic-user-190" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-191", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 32087.0, + "tier": "guest", + "user_id": "synthetic-user-191" + } + }, + "expected": { + "flag": "support_prompt", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "non-guest", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-192", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 32344.0, + "tier": "member", + "user_id": "synthetic-user-192" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-193", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 32601.0, + "tier": "member", + "user_id": "synthetic-user-193" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-194", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 32858.0, + "tier": "member", + "user_id": "synthetic-user-194" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-195", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 8115.0, + "tier": "employee", + "user_id": "synthetic-user-195" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-196", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 8372.0, + "tier": "guest", + "user_id": "synthetic-user-196" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "kiosk-rollout", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": true, + "reason": "conditions matched; stable bucket 236 was inside 0..3500" + } + ] + } + }, + { + "input": { + "case_id": "golden-197", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 8629.0, + "tier": "member", + "user_id": "synthetic-user-197" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 8629; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-198", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 8886.0, + "tier": "member", + "user_id": "synthetic-user-198" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-199", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 9143.0, + "tier": "member", + "user_id": "synthetic-user-199" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-200", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 9400.0, + "tier": "employee", + "user_id": "synthetic-user-200" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "employees-first", + "explanation": [ + { + "rule_id": "employees-first", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-201", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 9657.0, + "tier": "guest", + "user_id": "synthetic-user-201" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 9657; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-202", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 9914.0, + "tier": "member", + "user_id": "synthetic-user-202" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-203", + "flag": "support_prompt", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 10171.0, + "tier": "member", + "user_id": "synthetic-user-203" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-204", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 10428.0, + "tier": "member", + "user_id": "synthetic-user-204" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-205", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 10685.0, + "tier": "employee", + "user_id": "synthetic-user-205" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 10685; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-206", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 10942.0, + "tier": "guest", + "user_id": "synthetic-user-206" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-207", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 11199.0, + "tier": "member", + "user_id": "synthetic-user-207" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-208", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 11456.0, + "tier": "member", + "user_id": "synthetic-user-208" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 9046 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-209", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 11713.0, + "tier": "member", + "user_id": "synthetic-user-209" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 11713; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-210", + "flag": "receipt_style", + "context": { + "accessibility_mode": true, + "kiosk": false, + "store_size": 11970.0, + "tier": "employee", + "user_id": "synthetic-user-210" + } + }, + "expected": { + "flag": "receipt_style", + "value": "large-print", + "source": "accessible-store", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-211", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 12227.0, + "tier": "guest", + "user_id": "synthetic-user-211" + } + }, + "expected": { + "flag": "support_prompt", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "non-guest", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-212", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 12484.0, + "tier": "member", + "user_id": "synthetic-user-212" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 6171 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-213", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 12741.0, + "tier": "member", + "user_id": "synthetic-user-213" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 12741; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-214", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 12998.0, + "tier": "member", + "user_id": "synthetic-user-214" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-215", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 13255.0, + "tier": "employee", + "user_id": "synthetic-user-215" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-216", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 13512.0, + "tier": "guest", + "user_id": "synthetic-user-216" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-217", + "flag": "max_cart_items", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 13769.0, + "tier": "member", + "user_id": "synthetic-user-217" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 13769; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-218", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 14026.0, + "tier": "member", + "user_id": "synthetic-user-218" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-219", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 14283.0, + "tier": "member", + "user_id": "synthetic-user-219" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-220", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 14540.0, + "tier": "employee", + "user_id": "synthetic-user-220" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "employees-first", + "explanation": [ + { + "rule_id": "employees-first", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-221", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 14797.0, + "tier": "guest", + "user_id": "synthetic-user-221" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 14797; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-222", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 15054.0, + "tier": "member", + "user_id": "synthetic-user-222" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-223", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 15311.0, + "tier": "member", + "user_id": "synthetic-user-223" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-224", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 15568.0, + "tier": "member", + "user_id": "synthetic-user-224" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 5756 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-225", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 15825.0, + "tier": "employee", + "user_id": "synthetic-user-225" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 15825; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-226", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 16082.0, + "tier": "guest", + "user_id": "synthetic-user-226" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-227", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 16339.0, + "tier": "member", + "user_id": "synthetic-user-227" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-228", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 16596.0, + "tier": "member", + "user_id": "synthetic-user-228" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "attribute \"kiosk\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-229", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 16853.0, + "tier": "member", + "user_id": "synthetic-user-229" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 16853; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-230", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 17110.0, + "tier": "employee", + "user_id": "synthetic-user-230" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-231", + "flag": "support_prompt", + "context": { + "accessibility_mode": true, + "kiosk": false, + "store_size": 17367.0, + "tier": "guest", + "user_id": "synthetic-user-231" + } + }, + "expected": { + "flag": "support_prompt", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "non-guest", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-232", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 17624.0, + "tier": "member", + "user_id": "synthetic-user-232" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "kiosk-rollout", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": true, + "reason": "conditions matched; stable bucket 2401 was inside 0..3500" + } + ] + } + }, + { + "input": { + "case_id": "golden-233", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 17881.0, + "tier": "member", + "user_id": "synthetic-user-233" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 17881; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-234", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 18138.0, + "tier": "member", + "user_id": "synthetic-user-234" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-235", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 18395.0, + "tier": "employee", + "user_id": "synthetic-user-235" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-236", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 18652.0, + "tier": "guest", + "user_id": "synthetic-user-236" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"guest\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 9781 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-237", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 18909.0, + "tier": "member", + "user_id": "synthetic-user-237" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 18909; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-238", + "flag": "receipt_style", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 19166.0, + "tier": "member", + "user_id": "synthetic-user-238" + } + }, + "expected": { + "flag": "receipt_style", + "value": "large-print", + "source": "accessible-store", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-239", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 19423.0, + "tier": "member", + "user_id": "synthetic-user-239" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-240", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 19680.0, + "tier": "employee", + "user_id": "synthetic-user-240" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": true, + "source": "employees-first", + "explanation": [ + { + "rule_id": "employees-first", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-241", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 19937.0, + "tier": "guest", + "user_id": "synthetic-user-241" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 30.0, + "source": "default", + "explanation": [ + { + "rule_id": "large-store", + "matched": false, + "reason": "attribute \"store_size\" was 19937; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-242", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 20194.0, + "tier": "member", + "user_id": "synthetic-user-242" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-243", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 20451.0, + "tier": "member", + "user_id": "synthetic-user-243" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-244", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 20708.0, + "tier": "member", + "user_id": "synthetic-user-244" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 6846 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-245", + "flag": "max_cart_items", + "context": { + "accessibility_mode": true, + "kiosk": true, + "store_size": 20965.0, + "tier": "employee", + "user_id": "synthetic-user-245" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-246", + "flag": "receipt_style", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 21222.0, + "tier": "guest", + "user_id": "synthetic-user-246" + } + }, + "expected": { + "flag": "receipt_style", + "value": "compact", + "source": "default", + "explanation": [ + { + "rule_id": "accessible-store", + "matched": false, + "reason": "attribute \"accessibility_mode\" was false; condition did not match" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-247", + "flag": "support_prompt", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 21479.0, + "tier": "member", + "user_id": "synthetic-user-247" + } + }, + "expected": { + "flag": "support_prompt", + "value": true, + "source": "non-guest", + "explanation": [ + { + "rule_id": "non-guest", + "matched": true, + "reason": "all conditions matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-248", + "flag": "checkout_redesign", + "context": { + "accessibility_mode": false, + "kiosk": true, + "store_size": 21736.0, + "tier": "member", + "user_id": "synthetic-user-248" + } + }, + "expected": { + "flag": "checkout_redesign", + "value": false, + "source": "default", + "explanation": [ + { + "rule_id": "employees-first", + "matched": false, + "reason": "attribute \"tier\" was \"member\"; condition did not match" + }, + { + "rule_id": "kiosk-rollout", + "matched": false, + "reason": "stable bucket 4706 was outside 0..3500" + }, + { + "rule_id": "default", + "matched": true, + "reason": "no targeting rule matched" + } + ] + } + }, + { + "input": { + "case_id": "golden-249", + "flag": "max_cart_items", + "context": { + "accessibility_mode": false, + "kiosk": false, + "store_size": 21993.0, + "tier": "member", + "user_id": "synthetic-user-249" + } + }, + "expected": { + "flag": "max_cart_items", + "value": 50.0, + "source": "large-store", + "explanation": [ + { + "rule_id": "large-store", + "matched": true, + "reason": "all conditions matched" + } + ] + } + } +] \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/good-reload.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/good-reload.json new file mode 100644 index 0000000..52a33e5 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/good-reload.json @@ -0,0 +1,75 @@ +{ + "schema_version": 1, + "config_id": "demo-v2", + "salt": "retail-kiosk-v1", + "flags": { + "checkout_redesign": { + "default": false, + "rules": [ + { + "id": "all-kiosks-after-reload", + "conditions": [ + { + "attribute": "kiosk", + "op": "eq", + "value": true + } + ], + "serve": true, + "percentage": null + } + ] + }, + "max_cart_items": { + "default": 30.0, + "rules": [ + { + "id": "large-store", + "conditions": [ + { + "attribute": "store_size", + "op": "greater_than", + "value": 20000.0 + } + ], + "serve": 50.0, + "percentage": null + } + ] + }, + "receipt_style": { + "default": "compact", + "rules": [ + { + "id": "accessible-store", + "conditions": [ + { + "attribute": "accessibility_mode", + "op": "eq", + "value": true + } + ], + "serve": "large-print", + "percentage": null + } + ] + }, + "support_prompt": { + "default": false, + "rules": [ + { + "id": "non-guest", + "conditions": [ + { + "attribute": "tier", + "op": "not_eq", + "value": "guest" + } + ], + "serve": true, + "percentage": null + } + ] + } + } +} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/01-syntax.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/01-syntax.json new file mode 100644 index 0000000..b10d2a0 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/01-syntax.json @@ -0,0 +1 @@ +{not-json \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/02-unknown-field.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/02-unknown-field.json new file mode 100644 index 0000000..bfac84f --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/02-unknown-field.json @@ -0,0 +1 @@ +{"schema_version":1,"config_id":"x","salt":"s","extra":true,"flags":{"f":{"default":false,"rules":[]}}} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/03-wrong-schema.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/03-wrong-schema.json new file mode 100644 index 0000000..59db84e --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/03-wrong-schema.json @@ -0,0 +1 @@ +{"schema_version":2,"config_id":"x","salt":"s","flags":{"f":{"default":false,"rules":[]}}} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/04-empty-id.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/04-empty-id.json new file mode 100644 index 0000000..bb6a31c --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/04-empty-id.json @@ -0,0 +1 @@ +{"schema_version":1,"config_id":"","salt":"s","flags":{"f":{"default":false,"rules":[]}}} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/05-empty-salt.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/05-empty-salt.json new file mode 100644 index 0000000..d444f56 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/05-empty-salt.json @@ -0,0 +1 @@ +{"schema_version":1,"config_id":"x","salt":"","flags":{"f":{"default":false,"rules":[]}}} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/06-duplicate-flag-key.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/06-duplicate-flag-key.json new file mode 100644 index 0000000..525306b --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/06-duplicate-flag-key.json @@ -0,0 +1 @@ +{"schema_version":1,"config_id":"x","salt":"s","flags":{"f":{"default":false,"rules":[]},"f":{"default":true,"rules":[]}}} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/07-duplicate-rule-id.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/07-duplicate-rule-id.json new file mode 100644 index 0000000..321eb60 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/07-duplicate-rule-id.json @@ -0,0 +1 @@ +{"schema_version":1,"config_id":"x","salt":"s","flags":{"f":{"default":false,"rules":[{"id":"r","conditions":[{"attribute":"x","op":"eq","value":true}],"serve":true},{"id":"r","conditions":[{"attribute":"x","op":"eq","value":false}],"serve":false}]}}} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/08-empty-rule.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/08-empty-rule.json new file mode 100644 index 0000000..c7cded6 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/08-empty-rule.json @@ -0,0 +1 @@ +{"schema_version":1,"config_id":"x","salt":"s","flags":{"f":{"default":false,"rules":[{"id":"r","conditions":[],"serve":true}]}}} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/09-invalid-percentage.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/09-invalid-percentage.json new file mode 100644 index 0000000..85c4ff7 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/09-invalid-percentage.json @@ -0,0 +1 @@ +{"schema_version":1,"config_id":"x","salt":"s","flags":{"f":{"default":false,"rules":[{"id":"r","conditions":[],"serve":true,"percentage":{"attribute":"user_id","basis_points":10001}}]}}} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/10-invalid-operator-type.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/10-invalid-operator-type.json new file mode 100644 index 0000000..0fd9260 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/10-invalid-operator-type.json @@ -0,0 +1 @@ +{"schema_version":1,"config_id":"x","salt":"s","flags":{"f":{"default":false,"rules":[{"id":"r","conditions":[{"attribute":"age","op":"greater_than","value":"old"}],"serve":true}]}}} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/11-null-scalar.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/11-null-scalar.json new file mode 100644 index 0000000..e61d138 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/11-null-scalar.json @@ -0,0 +1 @@ +{"schema_version":1,"config_id":"x","salt":"s","flags":{"f":{"default":null,"rules":[]}}} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/12-missing-flags.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/12-missing-flags.json new file mode 100644 index 0000000..3fef90b --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/malformed/12-missing-flags.json @@ -0,0 +1 @@ +{"schema_version":1,"config_id":"x","salt":"s"} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/snapshot-reordered.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/snapshot-reordered.json new file mode 100644 index 0000000..99d9e37 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/snapshot-reordered.json @@ -0,0 +1 @@ +{"schema_version":1,"salt":"retail-kiosk-v1","flags":{"support_prompt":{"rules":[{"serve":true,"percentage":null,"id":"non-guest","conditions":[{"value":"guest","op":"not_eq","attribute":"tier"}]}],"default":false},"receipt_style":{"rules":[{"serve":"large-print","percentage":null,"id":"accessible-store","conditions":[{"value":true,"op":"eq","attribute":"accessibility_mode"}]}],"default":"compact"},"max_cart_items":{"rules":[{"serve":50.0,"percentage":null,"id":"large-store","conditions":[{"value":20000.0,"op":"greater_than","attribute":"store_size"}]}],"default":30.0},"checkout_redesign":{"rules":[{"serve":true,"percentage":null,"id":"employees-first","conditions":[{"value":"employee","op":"eq","attribute":"tier"}]},{"serve":true,"percentage":{"basis_points":3500,"attribute":"user_id"},"id":"kiosk-rollout","conditions":[{"value":true,"op":"eq","attribute":"kiosk"}]}],"default":false}},"config_id":"demo-v1"} diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/snapshot.json b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/snapshot.json new file mode 100644 index 0000000..e9655ee --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/fixtures/snapshot.json @@ -0,0 +1,90 @@ +{ + "schema_version": 1, + "config_id": "demo-v1", + "salt": "retail-kiosk-v1", + "flags": { + "checkout_redesign": { + "default": false, + "rules": [ + { + "id": "employees-first", + "conditions": [ + { + "attribute": "tier", + "op": "eq", + "value": "employee" + } + ], + "serve": true, + "percentage": null + }, + { + "id": "kiosk-rollout", + "conditions": [ + { + "attribute": "kiosk", + "op": "eq", + "value": true + } + ], + "serve": true, + "percentage": { + "attribute": "user_id", + "basis_points": 3500 + } + } + ] + }, + "max_cart_items": { + "default": 30.0, + "rules": [ + { + "id": "large-store", + "conditions": [ + { + "attribute": "store_size", + "op": "greater_than", + "value": 20000.0 + } + ], + "serve": 50.0, + "percentage": null + } + ] + }, + "receipt_style": { + "default": "compact", + "rules": [ + { + "id": "accessible-store", + "conditions": [ + { + "attribute": "accessibility_mode", + "op": "eq", + "value": true + } + ], + "serve": "large-print", + "percentage": null + } + ] + }, + "support_prompt": { + "default": false, + "rules": [ + { + "id": "non-guest", + "conditions": [ + { + "attribute": "tier", + "op": "not_eq", + "value": "guest" + } + ], + "serve": true, + "percentage": null + } + ] + } + } +} \ No newline at end of file diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/src/lib.rs b/developer-simulation/runs/2026-08-01--offline-flag-parity/src/lib.rs new file mode 100644 index 0000000..852bbe8 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/src/lib.rs @@ -0,0 +1,623 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs::File; +use std::io::Read; +use std::path::Path; +use std::sync::Arc; + +use serde::de::{self, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; + +pub const MAX_SNAPSHOT_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_FLAGS: usize = 5_000; +pub const MAX_RULES: usize = 50_000; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Scalar { + Bool(bool), + Number(f64), + String(String), +} + +impl fmt::Display for Scalar { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bool(value) => write!(f, "{value}"), + Self::Number(value) => write!(f, "{value}"), + Self::String(value) => write!(f, "{value:?}"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Snapshot { + pub schema_version: u32, + pub config_id: String, + pub salt: String, + pub flags: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Flag { + pub default: Scalar, + pub rules: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Rule { + pub id: String, + #[serde(default)] + pub conditions: Vec, + pub serve: Scalar, + #[serde(default)] + pub percentage: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Condition { + pub attribute: String, + pub op: Operator, + pub value: Scalar, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Operator { + Eq, + NotEq, + GreaterThan, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Percentage { + pub attribute: String, + pub basis_points: u16, +} + +pub type Context = BTreeMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EvaluationInput { + pub case_id: String, + pub flag: String, + pub context: Context, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RuleTrace { + pub rule_id: String, + pub matched: bool, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Decision { + pub flag: String, + pub value: Scalar, + pub source: String, + pub explanation: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GoldenCase { + pub input: EvaluationInput, + pub expected: Decision, +} + +#[derive(Debug, Clone)] +pub struct LoadError(String); + +impl LoadError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for LoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for LoadError {} + +pub fn load_snapshot_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let bytes = read_snapshot_file_bounded(path)?; + load_snapshot_bytes(&bytes) +} + +fn read_snapshot_file_bounded(path: &Path) -> Result, LoadError> { + let file = File::open(path) + .map_err(|error| LoadError::new(format!("open {}: {error}", path.display())))?; + let limit = u64::try_from(MAX_SNAPSHOT_BYTES) + .expect("snapshot byte limit fits u64") + .saturating_add(1); + let mut bytes = Vec::new(); + file.take(limit) + .read_to_end(&mut bytes) + .map_err(|error| LoadError::new(format!("read {}: {error}", path.display())))?; + if bytes.len() > MAX_SNAPSHOT_BYTES { + return Err(LoadError::new(format!( + "snapshot exceeds {MAX_SNAPSHOT_BYTES} byte file-read limit" + ))); + } + Ok(bytes) +} + +pub fn load_snapshot_bytes(bytes: &[u8]) -> Result { + if bytes.len() > MAX_SNAPSHOT_BYTES { + return Err(LoadError::new(format!( + "snapshot is {} bytes; limit is {MAX_SNAPSHOT_BYTES}", + bytes.len() + ))); + } + + let mut duplicate_check = serde_json::Deserializer::from_slice(bytes); + UniqueJson::deserialize(&mut duplicate_check) + .map_err(|error| LoadError::new(format!("invalid JSON: {error}")))?; + duplicate_check + .end() + .map_err(|error| LoadError::new(format!("invalid JSON: {error}")))?; + let snapshot: Snapshot = serde_json::from_slice(bytes) + .map_err(|error| LoadError::new(format!("invalid snapshot shape: {error}")))?; + validate_snapshot(&snapshot)?; + Ok(snapshot) +} + +pub fn validate_snapshot(snapshot: &Snapshot) -> Result<(), LoadError> { + if snapshot.schema_version != 1 { + return Err(LoadError::new("schema_version must be 1")); + } + if snapshot.config_id.trim().is_empty() { + return Err(LoadError::new("config_id must not be empty")); + } + if snapshot.salt.is_empty() { + return Err(LoadError::new("salt must not be empty")); + } + if snapshot.flags.is_empty() || snapshot.flags.len() > MAX_FLAGS { + return Err(LoadError::new(format!( + "flag count must be between 1 and {MAX_FLAGS}" + ))); + } + + let mut rule_count = 0usize; + for (flag_key, flag) in &snapshot.flags { + if flag_key.trim().is_empty() { + return Err(LoadError::new("flag keys must not be empty")); + } + rule_count = rule_count + .checked_add(flag.rules.len()) + .ok_or_else(|| LoadError::new("rule count overflow"))?; + if rule_count > MAX_RULES { + return Err(LoadError::new(format!("rule count exceeds {MAX_RULES}"))); + } + + let mut ids = BTreeSet::new(); + for rule in &flag.rules { + if rule.id.trim().is_empty() { + return Err(LoadError::new(format!( + "flag {flag_key:?} has an empty rule id" + ))); + } + if !ids.insert(rule.id.as_str()) { + return Err(LoadError::new(format!( + "flag {flag_key:?} has duplicate rule id {:?}", + rule.id + ))); + } + if rule.conditions.is_empty() && rule.percentage.is_none() { + return Err(LoadError::new(format!( + "rule {:?} must have a condition or percentage", + rule.id + ))); + } + for condition in &rule.conditions { + if condition.attribute.trim().is_empty() { + return Err(LoadError::new(format!( + "rule {:?} has an empty condition attribute", + rule.id + ))); + } + if matches!(condition.op, Operator::GreaterThan) + && !matches!(condition.value, Scalar::Number(_)) + { + return Err(LoadError::new(format!( + "rule {:?} greater_than requires a numeric comparison value", + rule.id + ))); + } + } + if let Some(percentage) = &rule.percentage { + if percentage.attribute.trim().is_empty() { + return Err(LoadError::new(format!( + "rule {:?} has an empty percentage attribute", + rule.id + ))); + } + if percentage.basis_points > 10_000 { + return Err(LoadError::new(format!( + "rule {:?} percentage exceeds 10000 basis points", + rule.id + ))); + } + } + } + } + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct Evaluator { + active: Arc, +} + +impl Evaluator { + pub fn new(snapshot: Snapshot) -> Self { + Self { + active: Arc::new(snapshot), + } + } + + pub fn config_id(&self) -> &str { + &self.active.config_id + } + + pub fn flag_keys(&self) -> impl Iterator { + self.active.flags.keys().map(String::as_str) + } + + pub fn evaluate(&self, flag_key: &str, context: &Context) -> Result { + evaluate_snapshot(&self.active, flag_key, context) + } + + pub fn reload_bytes(&mut self, bytes: &[u8]) -> Result<(), LoadError> { + let candidate = load_snapshot_bytes(bytes)?; + self.active = Arc::new(candidate); + Ok(()) + } + + pub fn reload_file(&mut self, path: impl AsRef) -> Result<(), LoadError> { + let path = path.as_ref(); + let bytes = read_snapshot_file_bounded(path)?; + self.reload_bytes(&bytes) + } +} + +pub fn evaluate_snapshot( + snapshot: &Snapshot, + flag_key: &str, + context: &Context, +) -> Result { + let flag = snapshot + .flags + .get(flag_key) + .ok_or_else(|| format!("unknown flag {flag_key:?}"))?; + let mut explanation = Vec::with_capacity(flag.rules.len() + 1); + + for rule in &flag.rules { + let mut failed = None; + for condition in &rule.conditions { + match context.get(&condition.attribute) { + None => { + failed = Some(format!("missing attribute {:?}", condition.attribute)); + break; + } + Some(actual) if !condition_matches(actual, condition) => { + failed = Some(format!( + "attribute {:?} was {actual}; condition did not match", + condition.attribute + )); + break; + } + Some(_) => {} + } + } + + if let Some(reason) = failed { + explanation.push(RuleTrace { + rule_id: rule.id.clone(), + matched: false, + reason, + }); + continue; + } + + if let Some(percentage) = &rule.percentage { + let Some(bucket_value) = context.get(&percentage.attribute) else { + explanation.push(RuleTrace { + rule_id: rule.id.clone(), + matched: false, + reason: format!("missing percentage attribute {:?}", percentage.attribute), + }); + continue; + }; + let bucket_key = scalar_bucket_key(bucket_value); + let bucket = stable_bucket(&snapshot.salt, flag_key, &rule.id, bucket_key.as_bytes()); + if bucket >= percentage.basis_points { + explanation.push(RuleTrace { + rule_id: rule.id.clone(), + matched: false, + reason: format!( + "stable bucket {bucket} was outside 0..{}", + percentage.basis_points + ), + }); + continue; + } + explanation.push(RuleTrace { + rule_id: rule.id.clone(), + matched: true, + reason: format!( + "conditions matched; stable bucket {bucket} was inside 0..{}", + percentage.basis_points + ), + }); + } else { + explanation.push(RuleTrace { + rule_id: rule.id.clone(), + matched: true, + reason: "all conditions matched".to_string(), + }); + } + + return Ok(Decision { + flag: flag_key.to_string(), + value: rule.serve.clone(), + source: rule.id.clone(), + explanation, + }); + } + + explanation.push(RuleTrace { + rule_id: "default".to_string(), + matched: true, + reason: "no targeting rule matched".to_string(), + }); + Ok(Decision { + flag: flag_key.to_string(), + value: flag.default.clone(), + source: "default".to_string(), + explanation, + }) +} + +fn condition_matches(actual: &Scalar, condition: &Condition) -> bool { + match condition.op { + Operator::Eq => actual == &condition.value, + Operator::NotEq => actual != &condition.value, + Operator::GreaterThan => match (actual, &condition.value) { + (Scalar::Number(actual), Scalar::Number(expected)) => actual > expected, + _ => false, + }, + } +} + +fn scalar_bucket_key(value: &Scalar) -> String { + match value { + Scalar::Bool(value) => format!("b:{value}"), + Scalar::Number(value) => format!("n:{value}"), + Scalar::String(value) => format!("s:{value}"), + } +} + +pub fn stable_bucket(salt: &str, flag: &str, rule: &str, attribute: &[u8]) -> u16 { + let mut hash = 0xcbf29ce484222325u64; + for part in [salt.as_bytes(), flag.as_bytes(), rule.as_bytes(), attribute] { + for byte in part { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash ^= 0; + hash = hash.wrapping_mul(0x100000001b3); + } + (hash % 10_000) as u16 +} + +/// Rough retained heap estimate for the active parsed snapshot. This excludes +/// allocator bookkeeping and process/runtime overhead; use OS peak RSS as the +/// acceptance measurement. +pub fn estimated_snapshot_heap(snapshot: &Snapshot) -> usize { + let mut bytes = + std::mem::size_of::() + snapshot.config_id.capacity() + snapshot.salt.capacity(); + for (flag_key, flag) in &snapshot.flags { + bytes += std::mem::size_of::() + flag_key.capacity(); + bytes += std::mem::size_of::() + scalar_heap(&flag.default); + bytes += flag.rules.capacity() * std::mem::size_of::(); + for rule in &flag.rules { + bytes += rule.id.capacity() + scalar_heap(&rule.serve); + bytes += rule.conditions.capacity() * std::mem::size_of::(); + for condition in &rule.conditions { + bytes += condition.attribute.capacity() + scalar_heap(&condition.value); + } + if let Some(percentage) = &rule.percentage { + bytes += percentage.attribute.capacity(); + } + } + } + bytes +} + +fn scalar_heap(value: &Scalar) -> usize { + match value { + Scalar::String(value) => value.capacity(), + Scalar::Bool(_) | Scalar::Number(_) => 0, + } +} + +struct UniqueJson; + +impl<'de> Deserialize<'de> for UniqueJson { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(UniqueJsonVisitor) + } +} + +struct UniqueJsonVisitor; + +impl<'de> Visitor<'de> for UniqueJsonVisitor { + type Value = UniqueJson; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value without duplicate object keys") + } + + fn visit_bool(self, value: bool) -> Result { + let _ = value; + Ok(UniqueJson) + } + + fn visit_i64(self, value: i64) -> Result { + let _ = value; + Ok(UniqueJson) + } + + fn visit_u64(self, value: u64) -> Result { + let _ = value; + Ok(UniqueJson) + } + + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + if value.is_finite() { + Ok(UniqueJson) + } else { + Err(E::custom("non-finite JSON number")) + } + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + let _ = value; + Ok(UniqueJson) + } + + fn visit_string(self, value: String) -> Result { + let _ = value; + Ok(UniqueJson) + } + + fn visit_none(self) -> Result { + Ok(UniqueJson) + } + + fn visit_unit(self) -> Result { + Ok(UniqueJson) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + while sequence.next_element::()?.is_some() {} + Ok(UniqueJson) + } + + fn visit_map(self, mut object: A) -> Result + where + A: MapAccess<'de>, + { + let mut keys = BTreeSet::new(); + while let Some(key) = object.next_key::()? { + if !keys.insert(key.clone()) { + return Err(de::Error::custom(format!( + "duplicate JSON object key {key:?}" + ))); + } + object.next_value::()?; + } + Ok(UniqueJson) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn duplicate_json_keys_are_rejected() { + let bytes = br#"{ + "schema_version":1, + "config_id":"a", + "salt":"s", + "flags":{}, + "flags":{} + }"#; + let error = load_snapshot_bytes(bytes).unwrap_err().to_string(); + assert!(error.contains("duplicate JSON object key \"flags\"")); + } + + #[test] + fn failed_reload_keeps_active_snapshot() { + let snapshot = one_flag_snapshot(); + let mut evaluator = Evaluator::new(snapshot); + let before = evaluator.evaluate("checkout", &Context::new()).unwrap(); + assert!(evaluator.reload_bytes(b"not json").is_err()); + assert_eq!(evaluator.config_id(), "one"); + assert_eq!( + evaluator.evaluate("checkout", &Context::new()).unwrap(), + before + ); + } + + #[test] + fn bucket_has_a_fixed_known_value() { + assert_eq!(stable_bucket("salt", "flag", "rule", b"user-42"), 7_307); + } + + #[test] + fn file_reader_stops_at_the_snapshot_limit() { + use std::io::{Seek, SeekFrom, Write}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "offline-flag-parity-oversized-{}-{nonce}.json", + std::process::id() + )); + let mut file = File::create(&path).expect("create sparse oversized fixture"); + file.seek(SeekFrom::Start(MAX_SNAPSHOT_BYTES as u64)) + .expect("seek to byte after limit"); + file.write_all(b"x").expect("finish sparse fixture"); + drop(file); + + let error = load_snapshot_file(&path) + .expect_err("oversized file must fail") + .to_string(); + std::fs::remove_file(&path).expect("remove sparse fixture"); + assert!(error.contains("file-read limit"), "{error}"); + } + + fn one_flag_snapshot() -> Snapshot { + Snapshot { + schema_version: 1, + config_id: "one".to_string(), + salt: "salt".to_string(), + flags: BTreeMap::from([( + "checkout".to_string(), + Flag { + default: Scalar::Bool(false), + rules: Vec::new(), + }, + )]), + } + } +} diff --git a/developer-simulation/runs/2026-08-01--offline-flag-parity/src/main.rs b/developer-simulation/runs/2026-08-01--offline-flag-parity/src/main.rs new file mode 100644 index 0000000..a548874 --- /dev/null +++ b/developer-simulation/runs/2026-08-01--offline-flag-parity/src/main.rs @@ -0,0 +1,624 @@ +use std::collections::BTreeMap; +use std::env; +use std::fs::{self, File}; +use std::hint::black_box; +use std::io::{BufRead, BufReader, BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use offline_flag_parity::{ + Condition, Context, EvaluationInput, Evaluator, Flag, GoldenCase, Operator, Percentage, Rule, + Scalar, Snapshot, estimated_snapshot_heap, evaluate_snapshot, load_snapshot_file, +}; +use serde::Serialize; +use serde_json::Value; + +type CliResult = Result>; + +fn main() { + if let Err(error) = run() { + eprintln!("error: {error}"); + std::process::exit(1); + } +} + +fn run() -> CliResult { + let mut args = env::args().skip(1); + match args.next().as_deref() { + Some("generate") => generate(Path::new(&required_arg(&mut args, "output directory")?)), + Some("verify") => verify(Path::new(&required_arg(&mut args, "fixture directory")?)), + Some("demo") => demo(Path::new(&required_arg(&mut args, "fixture directory")?)), + Some("eval") => evaluate_ndjson( + Path::new(&required_arg(&mut args, "snapshot path")?), + Path::new(&required_arg(&mut args, "NDJSON context path")?), + ), + Some("fingerprint") => { + fingerprint(Path::new(&required_arg(&mut args, "fixture directory")?)) + } + Some("generate-benchmark") => { + generate_benchmark(Path::new(&required_arg(&mut args, "snapshot path")?)) + } + Some("bench") => benchmark(Path::new(&required_arg( + &mut args, + "generated benchmark snapshot path", + )?)), + _ => { + eprintln!( + "usage: offline-flag-parity " + ); + std::process::exit(2); + } + } +} + +fn required_arg(args: &mut impl Iterator, name: &str) -> CliResult { + args.next().ok_or_else(|| format!("missing {name}").into()) +} + +fn generate(directory: &Path) -> CliResult { + fs::create_dir_all(directory)?; + let malformed = directory.join("malformed"); + fs::create_dir_all(&malformed)?; + + let snapshot = demo_snapshot("demo-v1", false); + write_json(directory.join("snapshot.json"), &snapshot)?; + write_reordered_json(directory.join("snapshot-reordered.json"), &snapshot)?; + write_json( + directory.join("good-reload.json"), + &demo_snapshot("demo-v2", true), + )?; + fs::write( + directory.join("bad-reload.json"), + br#"{"schema_version":1,"config_id":"bad","salt":"retail-kiosk-v1","flags":{"checkout_redesign":{"default":false,"rules":[{"id":"bad-rollout","conditions":[],"serve":true,"percentage":{"attribute":"user_id","basis_points":10001}}]}}}"#, + )?; + + let evaluator = Evaluator::new(snapshot); + let context_file = File::create(directory.join("contexts.ndjson"))?; + let golden_file = File::create(directory.join("golden.json"))?; + let mut contexts = BufWriter::new(context_file); + let mut golden = Vec::with_capacity(250); + for index in 0..250 { + let input = golden_input(index); + serde_json::to_writer(&mut contexts, &input)?; + contexts.write_all(b"\n")?; + let expected = evaluator.evaluate(&input.flag, &input.context)?; + golden.push(GoldenCase { input, expected }); + } + contexts.flush()?; + serde_json::to_writer_pretty(BufWriter::new(golden_file), &golden)?; + + let malformed_cases = malformed_cases(); + for (name, contents) in &malformed_cases { + fs::write(malformed.join(name), contents)?; + } + + println!( + "generated snapshot, reordered snapshot, 250 NDJSON contexts/golden cases, reload fixtures, and {} malformed snapshots in {}", + malformed_cases.len(), + directory.display() + ); + Ok(()) +} + +fn verify(directory: &Path) -> CliResult { + let snapshot = load_snapshot_file(directory.join("snapshot.json"))?; + let reordered = load_snapshot_file(directory.join("snapshot-reordered.json"))?; + let golden: Vec = + serde_json::from_reader(File::open(directory.join("golden.json"))?)?; + if golden.len() != 250 { + return Err(format!("expected 250 golden cases, found {}", golden.len()).into()); + } + + for case in &golden { + let actual = evaluate_snapshot(&snapshot, &case.input.flag, &case.input.context)?; + if actual != case.expected { + return Err(format!("golden mismatch for {}", case.input.case_id).into()); + } + let reordered_actual = + evaluate_snapshot(&reordered, &case.input.flag, &case.input.context)?; + if reordered_actual != actual { + return Err(format!("object-order mismatch for {}", case.input.case_id).into()); + } + } + + let malformed_directory = directory.join("malformed"); + let mut malformed_paths: Vec<_> = fs::read_dir(&malformed_directory)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>()?; + malformed_paths.sort(); + for path in &malformed_paths { + if load_snapshot_file(path).is_ok() { + return Err(format!("malformed fixture activated: {}", path.display()).into()); + } + } + + let mut evaluator = Evaluator::new(snapshot); + let before = evaluator.evaluate("checkout_redesign", &golden[0].input.context)?; + evaluator.reload_file(directory.join("good-reload.json"))?; + let after_good = evaluator.evaluate("checkout_redesign", &golden[0].input.context)?; + if evaluator.config_id() != "demo-v2" { + return Err("good reload did not activate demo-v2".into()); + } + let rejected = evaluator.reload_file(directory.join("bad-reload.json")); + if rejected.is_ok() || evaluator.config_id() != "demo-v2" { + return Err("bad reload did not preserve demo-v2".into()); + } + let after_bad = evaluator.evaluate("checkout_redesign", &golden[0].input.context)?; + if after_bad != after_good { + return Err("bad reload partially changed active decisions".into()); + } + + println!( + "verified {} golden cases; object ordering invariant; {} malformed snapshots rejected; good reload {} -> {}; bad reload preserved {}", + golden.len(), + malformed_paths.len(), + before.value, + after_good.value, + after_bad.value + ); + Ok(()) +} + +fn demo(directory: &Path) -> CliResult { + let snapshot = load_snapshot_file(directory.join("snapshot.json"))?; + let mut evaluator = Evaluator::new(snapshot); + let inputs = read_inputs(&directory.join("contexts.ndjson"))?; + + println!("active config: {}", evaluator.config_id()); + for input in inputs.iter().take(4) { + let decision = evaluator.evaluate(&input.flag, &input.context)?; + println!("{}", serde_json::to_string(&decision)?); + } + + evaluator.reload_file(directory.join("good-reload.json"))?; + let after_good = evaluator.evaluate("checkout_redesign", &inputs[0].context)?; + println!( + "good reload activated config {}: {}", + evaluator.config_id(), + serde_json::to_string(&after_good)? + ); + + let error = evaluator + .reload_file(directory.join("bad-reload.json")) + .expect_err("bad reload should fail"); + let after_bad = evaluator.evaluate("checkout_redesign", &inputs[0].context)?; + println!( + "bad reload rejected ({error}); active config remains {}: {}", + evaluator.config_id(), + serde_json::to_string(&after_bad)? + ); + Ok(()) +} + +fn evaluate_ndjson(snapshot_path: &Path, contexts_path: &Path) -> CliResult { + let evaluator = Evaluator::new(load_snapshot_file(snapshot_path)?); + for input in read_inputs(contexts_path)? { + let decision = evaluator.evaluate(&input.flag, &input.context)?; + println!("{}", serde_json::to_string(&decision)?); + } + Ok(()) +} + +fn fingerprint(directory: &Path) -> CliResult { + let evaluator = Evaluator::new(load_snapshot_file(directory.join("snapshot.json"))?); + let mut hash = 0xcbf29ce484222325u64; + for input in read_inputs(&directory.join("contexts.ndjson"))? { + let decision = evaluator.evaluate(&input.flag, &input.context)?; + let bytes = serde_json::to_vec(&decision)?; + for byte in bytes { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + } + println!("{hash:016x}"); + Ok(()) +} + +fn generate_benchmark(path: &Path) -> CliResult { + let snapshot = benchmark_snapshot(); + serde_json::to_writer(BufWriter::new(File::create(path)?), &snapshot)?; + let bytes = fs::metadata(path)?.len(); + println!( + "generated 5000-flag/50000-rule benchmark snapshot: {} bytes at {}", + bytes, + path.display() + ); + Ok(()) +} + +fn benchmark(path: &Path) -> CliResult { + let snapshot_bytes = fs::metadata(path)?.len(); + let snapshot = load_snapshot_file(path)?; + + let flag_count = snapshot.flags.len(); + let rule_count: usize = snapshot.flags.values().map(|flag| flag.rules.len()).sum(); + let heap_estimate = estimated_snapshot_heap(&snapshot); + let mut evaluator = Evaluator::new(snapshot); + let (reload_result, peak_reload_rss_bytes) = sample_peak_rss(|| evaluator.reload_file(path)); + reload_result?; + let mut checksum = 0u64; + let mut p95_values = Vec::new(); + + for round in 0..5u64 { + let mut timings = Vec::with_capacity(20_000); + for index in 0..20_000u64 { + let flag = format!("flag-{}", (index * 2_653 + round * 977) % 5_000); + let context = benchmark_context(index, round); + let start = Instant::now(); + let decision = evaluator.evaluate(&flag, &context)?; + let elapsed = start.elapsed(); + black_box(&decision); + timings.push(elapsed); + checksum = checksum.wrapping_add(decision.source.len() as u64); + } + timings.sort_unstable(); + let p95 = timings[(timings.len() * 95) / 100]; + p95_values.push(p95); + println!("round {} p95_ns={}", round + 1, p95.as_nanos()); + } + p95_values.sort_unstable(); + let median_p95 = p95_values[p95_values.len() / 2]; + let max_p95 = *p95_values.last().expect("five p95 values"); + let current_rss_bytes = current_rss_bytes().unwrap_or(0); + println!( + "benchmark flags={flag_count} rules={rule_count} evaluations=100000 snapshot_bytes={snapshot_bytes} estimated_active_heap_bytes={heap_estimate} sampled_peak_same_size_reload_rss_bytes={peak_reload_rss_bytes} current_rss_bytes={current_rss_bytes} median_p95_ns={} max_p95_ns={} checksum={checksum}", + median_p95.as_nanos(), + max_p95.as_nanos() + ); + if max_p95 >= Duration::from_micros(250) { + return Err(format!("maximum measured p95 {max_p95:?} exceeded 250us").into()); + } + if peak_reload_rss_bytes >= 64 * 1024 * 1024 { + return Err( + format!("sampled peak reload RSS {peak_reload_rss_bytes} exceeded 64 MiB").into(), + ); + } + Ok(()) +} + +fn sample_peak_rss(work: impl FnOnce() -> T) -> (T, u64) { + let running = Arc::new(AtomicBool::new(true)); + let maximum = Arc::new(AtomicU64::new(current_rss_bytes().unwrap_or(0))); + let sampler_running = Arc::clone(&running); + let sampler_maximum = Arc::clone(&maximum); + let sampler = thread::spawn(move || { + while sampler_running.load(Ordering::Relaxed) { + if let Some(rss) = current_rss_bytes() { + sampler_maximum.fetch_max(rss, Ordering::Relaxed); + } + thread::sleep(Duration::from_millis(1)); + } + }); + let result = work(); + if let Some(rss) = current_rss_bytes() { + maximum.fetch_max(rss, Ordering::Relaxed); + } + running.store(false, Ordering::Relaxed); + sampler.join().expect("RSS sampler thread should not panic"); + (result, maximum.load(Ordering::Relaxed)) +} + +fn current_rss_bytes() -> Option { + let output = Command::new("/bin/ps") + .args(["-o", "rss=", "-p", &std::process::id().to_string()]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let kib = String::from_utf8(output.stdout) + .ok()? + .trim() + .parse::() + .ok()?; + Some(kib * 1024) +} + +fn demo_snapshot(config_id: &str, reloaded: bool) -> Snapshot { + let checkout_rules = if reloaded { + vec![Rule { + id: "all-kiosks-after-reload".to_string(), + conditions: vec![Condition { + attribute: "kiosk".to_string(), + op: Operator::Eq, + value: Scalar::Bool(true), + }], + serve: Scalar::Bool(true), + percentage: None, + }] + } else { + vec![ + Rule { + id: "employees-first".to_string(), + conditions: vec![Condition { + attribute: "tier".to_string(), + op: Operator::Eq, + value: Scalar::String("employee".to_string()), + }], + serve: Scalar::Bool(true), + percentage: None, + }, + Rule { + id: "kiosk-rollout".to_string(), + conditions: vec![Condition { + attribute: "kiosk".to_string(), + op: Operator::Eq, + value: Scalar::Bool(true), + }], + serve: Scalar::Bool(true), + percentage: Some(Percentage { + attribute: "user_id".to_string(), + basis_points: 3_500, + }), + }, + ] + }; + + Snapshot { + schema_version: 1, + config_id: config_id.to_string(), + salt: "retail-kiosk-v1".to_string(), + flags: BTreeMap::from([ + ( + "checkout_redesign".to_string(), + Flag { + default: Scalar::Bool(false), + rules: checkout_rules, + }, + ), + ( + "max_cart_items".to_string(), + Flag { + default: Scalar::Number(30.0), + rules: vec![Rule { + id: "large-store".to_string(), + conditions: vec![Condition { + attribute: "store_size".to_string(), + op: Operator::GreaterThan, + value: Scalar::Number(20_000.0), + }], + serve: Scalar::Number(50.0), + percentage: None, + }], + }, + ), + ( + "receipt_style".to_string(), + Flag { + default: Scalar::String("compact".to_string()), + rules: vec![Rule { + id: "accessible-store".to_string(), + conditions: vec![Condition { + attribute: "accessibility_mode".to_string(), + op: Operator::Eq, + value: Scalar::Bool(true), + }], + serve: Scalar::String("large-print".to_string()), + percentage: None, + }], + }, + ), + ( + "support_prompt".to_string(), + Flag { + default: Scalar::Bool(false), + rules: vec![Rule { + id: "non-guest".to_string(), + conditions: vec![Condition { + attribute: "tier".to_string(), + op: Operator::NotEq, + value: Scalar::String("guest".to_string()), + }], + serve: Scalar::Bool(true), + percentage: None, + }], + }, + ), + ]), + } +} + +fn golden_input(index: usize) -> EvaluationInput { + let flags = [ + "checkout_redesign", + "max_cart_items", + "receipt_style", + "support_prompt", + ]; + let mut context = Context::new(); + context.insert( + "user_id".to_string(), + Scalar::String(format!("synthetic-user-{index:03}")), + ); + context.insert("kiosk".to_string(), Scalar::Bool(!index.is_multiple_of(3))); + context.insert( + "tier".to_string(), + Scalar::String( + match index % 5 { + 0 => "employee", + 1 => "guest", + _ => "member", + } + .to_string(), + ), + ); + context.insert( + "store_size".to_string(), + Scalar::Number((8_000 + (index * 257) % 25_000) as f64), + ); + context.insert( + "accessibility_mode".to_string(), + Scalar::Bool(index.is_multiple_of(7)), + ); + EvaluationInput { + case_id: format!("golden-{index:03}"), + flag: flags[index % flags.len()].to_string(), + context, + } +} + +fn benchmark_snapshot() -> Snapshot { + let mut flags = BTreeMap::new(); + for flag_index in 0..5_000usize { + let mut rules = Vec::with_capacity(10); + for rule_index in 0..10usize { + rules.push(Rule { + id: format!("r{rule_index}"), + conditions: vec![Condition { + attribute: "segment".to_string(), + op: Operator::Eq, + value: Scalar::Number(rule_index as f64), + }], + serve: Scalar::Bool((flag_index + rule_index) % 2 == 0), + percentage: if rule_index % 3 == 0 { + Some(Percentage { + attribute: "user_id".to_string(), + basis_points: 7_500, + }) + } else { + None + }, + }); + } + flags.insert( + format!("flag-{flag_index}"), + Flag { + default: Scalar::Bool(false), + rules, + }, + ); + } + Snapshot { + schema_version: 1, + config_id: "synthetic-5000x10".to_string(), + salt: "benchmark-salt".to_string(), + flags, + } +} + +fn benchmark_context(index: u64, round: u64) -> Context { + BTreeMap::from([ + ( + "segment".to_string(), + Scalar::Number(((index * 7 + round * 3) % 12) as f64), + ), + ( + "user_id".to_string(), + Scalar::String(format!("bench-user-{index}-{round}")), + ), + ("kiosk".to_string(), Scalar::Bool(true)), + ]) +} + +fn malformed_cases() -> Vec<(&'static str, Vec)> { + vec![ + ("01-syntax.json", b"{not-json".to_vec()), + ( + "02-unknown-field.json", + br#"{"schema_version":1,"config_id":"x","salt":"s","extra":true,"flags":{"f":{"default":false,"rules":[]}}}"#.to_vec(), + ), + ( + "03-wrong-schema.json", + br#"{"schema_version":2,"config_id":"x","salt":"s","flags":{"f":{"default":false,"rules":[]}}}"#.to_vec(), + ), + ( + "04-empty-id.json", + br#"{"schema_version":1,"config_id":"","salt":"s","flags":{"f":{"default":false,"rules":[]}}}"#.to_vec(), + ), + ( + "05-empty-salt.json", + br#"{"schema_version":1,"config_id":"x","salt":"","flags":{"f":{"default":false,"rules":[]}}}"#.to_vec(), + ), + ( + "06-duplicate-flag-key.json", + br#"{"schema_version":1,"config_id":"x","salt":"s","flags":{"f":{"default":false,"rules":[]},"f":{"default":true,"rules":[]}}}"#.to_vec(), + ), + ( + "07-duplicate-rule-id.json", + br#"{"schema_version":1,"config_id":"x","salt":"s","flags":{"f":{"default":false,"rules":[{"id":"r","conditions":[{"attribute":"x","op":"eq","value":true}],"serve":true},{"id":"r","conditions":[{"attribute":"x","op":"eq","value":false}],"serve":false}]}}}"#.to_vec(), + ), + ( + "08-empty-rule.json", + br#"{"schema_version":1,"config_id":"x","salt":"s","flags":{"f":{"default":false,"rules":[{"id":"r","conditions":[],"serve":true}]}}}"#.to_vec(), + ), + ( + "09-invalid-percentage.json", + br#"{"schema_version":1,"config_id":"x","salt":"s","flags":{"f":{"default":false,"rules":[{"id":"r","conditions":[],"serve":true,"percentage":{"attribute":"user_id","basis_points":10001}}]}}}"#.to_vec(), + ), + ( + "10-invalid-operator-type.json", + br#"{"schema_version":1,"config_id":"x","salt":"s","flags":{"f":{"default":false,"rules":[{"id":"r","conditions":[{"attribute":"age","op":"greater_than","value":"old"}],"serve":true}]}}}"#.to_vec(), + ), + ( + "11-null-scalar.json", + br#"{"schema_version":1,"config_id":"x","salt":"s","flags":{"f":{"default":null,"rules":[]}}}"#.to_vec(), + ), + ( + "12-missing-flags.json", + br#"{"schema_version":1,"config_id":"x","salt":"s"}"#.to_vec(), + ), + ] +} + +fn read_inputs(path: &Path) -> CliResult> { + let reader = BufReader::new(File::open(path)?); + let mut inputs = Vec::new(); + for (index, line) in reader.lines().enumerate() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let input = serde_json::from_str(&line) + .map_err(|error| format!("{} line {}: {error}", path.display(), index + 1))?; + inputs.push(input); + } + Ok(inputs) +} + +fn write_json(path: PathBuf, value: &impl Serialize) -> CliResult { + serde_json::to_writer_pretty(BufWriter::new(File::create(path)?), value)?; + Ok(()) +} + +fn write_reordered_json(path: PathBuf, value: &impl Serialize) -> CliResult { + let value = serde_json::to_value(value)?; + let mut writer = BufWriter::new(File::create(path)?); + write_value_reverse(&mut writer, &value)?; + writer.write_all(b"\n")?; + Ok(()) +} + +fn write_value_reverse(writer: &mut impl Write, value: &Value) -> CliResult { + match value { + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + serde_json::to_writer(writer, value)?; + } + Value::Array(values) => { + writer.write_all(b"[")?; + for (index, value) in values.iter().enumerate() { + if index > 0 { + writer.write_all(b",")?; + } + write_value_reverse(writer, value)?; + } + writer.write_all(b"]")?; + } + Value::Object(values) => { + writer.write_all(b"{")?; + for (index, (key, value)) in values.iter().rev().enumerate() { + if index > 0 { + writer.write_all(b",")?; + } + serde_json::to_writer(&mut *writer, key)?; + writer.write_all(b":")?; + write_value_reverse(writer, value)?; + } + writer.write_all(b"}")?; + } + } + Ok(()) +} diff --git a/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/Cargo.toml b/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/Cargo.toml new file mode 100644 index 0000000..52889f5 --- /dev/null +++ b/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "carrier-label-ambiguity" +version = "0.1.0" +edition = "2024" +publish = false + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "deny" diff --git a/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/README.md b/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/README.md new file mode 100644 index 0000000..61ba9b5 --- /dev/null +++ b/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/README.md @@ -0,0 +1,61 @@ +# Carrier label ambiguity reliability core + +This is a self-contained Rust simulation of the safety boundary around a carrier label purchase. It models the existing PostgreSQL transaction as a checksummed local decision journal, while the carrier simulator remains a separate authority for charges. It deliberately does not use a BogKit crate; the reason is recorded in `TRIAL_REPORT.md`. + +The central rule is simple: persist one purchase intent before contacting the carrier, and never automatically call the purchase endpoint again after that intent exists. A missing response is resolved by carrier lookup, a trustworthy callback, or `needs_review`. + +## What is included + +- deterministic carrier outcomes, including exactly 10% ambiguous timeouts; +- a synced workflow journal whose recorded post-state is verified by replay; +- repair of a recognized incomplete final journal record before later appends; +- duplicate and reordered carrier callbacks with monotonic state changes; +- a reconciliation worker that resolves known labels and exposes unknown outcomes; +- four real child-process exits around network and persistence boundaries; +- a 20,000-shipment, 30-seed acceptance harness with invariant checks; +- no third-party dependencies, secrets, private data, databases, or generated fixtures. + +The journal is a stand-in for an existing PostgreSQL transaction, not a proposed production storage replacement. In production, each journal commit corresponds to updating the shipment row and inserting the decision-history row in one PostgreSQL transaction. + +## Exact reproduction + +Run these commands from the repository's `developer-simulation` directory. + +```sh +cargo fmt --manifest-path runs/2026-08-02--carrier-label-ambiguity/Cargo.toml -- --check +cargo clippy -p carrier-label-ambiguity --all-targets -- -D warnings +cargo test -p carrier-label-ambiguity --all-targets +cargo build -p carrier-label-ambiguity --release + +DEMO_ROOT="$(mktemp -d /private/tmp/carrier-label-demo.XXXXXX)" +./target/release/carrier-label-ambiguity demo --dir "$DEMO_ROOT/run" + +CRASH_ROOT="$(mktemp -d /private/tmp/carrier-label-crashes.XXXXXX)" +./target/release/carrier-label-ambiguity crash-demo --dir "$CRASH_ROOT/run" + +ACCEPTANCE_ROOT="$(mktemp -d /private/tmp/carrier-label-acceptance.XXXXXX)" +./target/release/carrier-label-ambiguity acceptance \ + --dir "$ACCEPTANCE_ROOT/run" \ + --shipments 20000 \ + --seeds 30 +``` + +On macOS, measure peak resident memory while running the same realistic fixture: + +```sh +MEASURED_ROOT="$(mktemp -d /private/tmp/carrier-label-measured.XXXXXX)" +./runs/2026-08-02--carrier-label-ambiguity/scripts/measure-acceptance.sh \ + ./target/release/carrier-label-ambiguity \ + "$MEASURED_ROOT/run" \ + "$MEASURED_ROOT/output.log" +``` + +The measurement script uses `ps` to sample the process. A restricted sandbox may need permission to inspect the local process. + +## Expected completion lines + +The small demo ends with `ACCEPTANCE PASS seeds=1 shipments=100`. The crash harness ends with `CRASH/RESTART PASS scenarios=4 automatic_retries=0`. The realistic fixture ends with `ACCEPTANCE PASS seeds=30 shipments=600000` and the measurement script prints `MEASURED PEAK RSS`. + +The fixture fails immediately if a purchase is called twice, a carrier-created label is lost, a timeout without authoritative evidence avoids review, a shipment remains nonterminal, convergence exceeds 60 simulated seconds, an attempt disappears across restart, or replay disagrees with recorded workflow state. The focused journal regression also proves partial write, reopen, later commit, and second reopen. + +The durability evidence covers ordinary process termination after completed sync calls on the measured host. It is not a PostgreSQL, concurrent-worker, network, kernel-failure, or power-loss qualification. diff --git a/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/TRIAL_REPORT.md b/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/TRIAL_REPORT.md new file mode 100644 index 0000000..d06a0ea --- /dev/null +++ b/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/TRIAL_REPORT.md @@ -0,0 +1,187 @@ +# Trial report: carrier-label ambiguity + +Date: 2026-08-02 +Role: fulfillment-platform engineer, intermediate Rust and strong TypeScript, no prior BogKit knowledge +Source revision: `80fd3c9a023e877fff2e5d127accca386d437af0` + +## Outcome + +After skeptical-review correction, the reliability prototype passes the requested 20,000-shipment workload for all 30 deterministic seeds. Across 600,000 simulated shipments it made exactly one carrier purchase call per shipment, exposed 30,154 unresolved outcomes as `needs_review`, reconstructed all 3,478,477 decisions from the synced journal model, and converged by 30 simulated seconds. Developer, reviewer, and post-fix optimized runs took 2.795–2.857 local seconds and sampled 11.48–18.20 MiB peak resident memory on one host. + +BogKit decision: **no fit for this reliability core**. Fold provides a useful durable incremental-view engine, but introducing its embedded store beside the required PostgreSQL authority would create another persistence boundary. It does not make the carrier request and PostgreSQL update atomic, and its retraction/materialization model does not replace the required write-ahead purchase intent, carrier reconciliation, or human review state. ESE and ANNy are unrelated to this problem. + +## Ordered discovery and friction trail + +1. Read the public root `README.md`. It recommends generating a project with `scripts/new-project.sh` and describes Fold, ESE, ANNy, and four examples. +2. Read public examples in the README order: `starter`, `timeseries`, `chat`, and `search`, including each example's `Cargo.toml` and `src/main.rs`. +3. `starter` established that Fold has transactional writes, consistent reads, persistence, counts, and bags. +4. `timeseries` showed keyed incremental aggregates and retractions into materialized tables. +5. `chat` showed one writer owning a Fold stream and publishing consistent snapshots. +6. `search` showed `KeyedStream` upsert/retraction and confirmed ESE/ANNy target search rather than workflow safety. +7. Froze the checkout baseline at `80fd3c9a023e877fff2e5d127accca386d437af0`; `git status --short` was empty. Toolchain was `rustc 1.95.0` and `cargo 1.95.0`. +8. `cargo fmt --all -- --check` failed on existing formatting differences in `examples/search/src/main.rs`. +9. `cargo test --workspace --all-targets` stopped while ESE's build script attempted to download `model.safetensors`; DNS/network access was unavailable. This occurred before the full baseline could run. +10. `cargo test -p fold --all-targets` passed all 18 Fold tests in 1.66 seconds. +11. `cargo clippy -p fold --all-targets -- -D warnings` was blocked by five existing `needless_range_loop` findings in ANNy. +12. Selected no BogKit dependency and built a dependency-free, archive-safe prototype so the test addressed only the carrier/PostgreSQL ambiguity boundary. +13. The first complete implementation passed functionally but measured 293.66 MiB peak resident memory. Inspection found that audit verification retained every decoded record and repeatedly rescanned the full history. +14. Replaced retained audit records with streaming replay and per-shipment attempt counters. The developer's final realistic run measured 18.16 MiB and 2.799 seconds. The discarded high-memory implementation was not available for independent review, so its historical 293.66 MiB observation is not treated as reproduced evidence. +15. Skeptical review reproduced the main workload but found that reopen ignored an incomplete journal tail without truncating it. A later commit appended behind the bad bytes and the next reopen failed its checksum. Reopen now truncates and syncs the recognized tail before returning, and the regression covers partial write, reopen, later commit, and second reopen. +16. Review also found that a sandboxed measurement could report zero when `ps` returned no samples. The script now fails without a valid sample. A post-fix process-inspected run measured 11.48 MiB and 2.795 seconds. + +## Baseline comparison + +The unsafe baseline described in the problem retries after a timeout. In this fixture, every seed has exactly 2,000 ambiguous timeouts. Across 30 seeds, about half of the 60,000 ambiguous calls created a paid label at the carrier. Replaying purchase for any of those would create a second paid label when the carrier does not provide a trusted idempotency guarantee. + +The prototype changes only the reliability policy: + +| Boundary | Unsafe baseline | Prototype | +| --- | --- | --- | +| Before carrier call | May have no durable attempt | Persists one attempt first | +| Missing response | Automatically retries purchase | Never purchases again; reconciles | +| Carrier lookup inconclusive | May remain hidden in a retry queue | Becomes `needs_review` at 30 seconds | +| Callback order/duplicates | Can race or downgrade state | Monotonic reducer; same final carrier transaction | +| Restart after carrier charge | Can repeat the purchase | Recovers intent and finds the carrier label | +| Audit | State and attempts can disagree | Every stored post-state is checked by fresh replay | + +This prototype does not claim that the pre-existing service was executed. The baseline comparison is against the explicitly supplied unsafe retry behavior; the repository baseline checks above cover BogKit itself. + +## Final validation evidence + +All commands below ran from the sanitized checkout root. + +### Prototype quality gates + +```sh +cargo fmt --all --manifest-path trial-output/Cargo.toml -- --check +cargo clippy --manifest-path trial-output/Cargo.toml --all-targets -- -D warnings +cargo test --manifest-path trial-output/Cargo.toml --all-targets +cargo build --release --manifest-path trial-output/Cargo.toml +``` + +Observed after the review fix: formatting passed; strict lint passed with warnings denied; all three focused tests passed; the release build passed. Tests cover callback order and duplication, partial-tail repair followed by a later commit and second reopen, and a three-seed end-to-end fixture. + +### Demonstration + +```sh +cargo run --manifest-path trial-output/Cargo.toml -- demo \ + --dir trial-output/.verification-demo +``` + +Observed: 100 shipments passed; 10 ambiguous timeouts were injected; 94 carrier labels were safely recovered; five unresolved calls became review items; 591 decisions replayed; four simulated restarts recovered; maximum convergence was 30 seconds. + +### Actual process exits + +```sh +cargo run --manifest-path trial-output/Cargo.toml -- crash-demo \ + --dir trial-output/.verification-crashes +``` + +Observed: all four child processes exited with the expected crash code. Recovery after a durable intent but no authoritative label ended in `NeedsReview`; exits after carrier creation, local confirmation, and callback persistence all recovered as `Purchased`. Every case retained one attempt, and automatic retries were zero. + +### Realistic acceptance and resource measurement + +```sh +trial-output/scripts/measure-acceptance.sh \ + trial-output/target/release/carrier-label-ambiguity \ + trial-output/.verification-final \ + trial-output/.verification-final.log +``` + +Observed post-review final lines: + +```text +ACCEPTANCE PASS seeds=30 shipments=600000 paid_labels=542813 needs_review=30154 decisions=3478477 max_final_at=30s journal_mib=186.73 elapsed_seconds=2.795 +MEASURED PEAK RSS: 11760 KiB (11.48 MiB) +``` + +Per seed: exactly 20,000 shipments, exactly 2,000 ambiguous timeouts, four injected restart boundaries, no second purchase calls, and no nonterminal shipment. Duplicate/reordered callbacks were injected for every created label; the reducer test directly compares them with one ordered callback. + +## Categorized findings + +### F1: A durable attempt is the safety barrier + +- Evidence: four child-process crash cases plus all 600,000 shipments retained exactly one attempt and performed no automatic second purchase. +- Severity: critical. +- Confidence: high; directly asserted and replayed. +- Reproduction: run `crash-demo`, then the realistic acceptance command. +- Smallest improvement: in the existing service, commit the attempt row before the HTTP call and prohibit the retry worker from purchasing when any attempt exists. + +### F2: Unknown is a durable business state, not a retry condition + +- Evidence: 30,154 carrier-inconclusive outcomes became `needs_review` at 30 simulated seconds; none triggered another purchase. +- Severity: critical. +- Confidence: high; deterministic across all 30 seeds. +- Reproduction: run the realistic acceptance command and inspect each seed's `needs_review` count. +- Smallest improvement: add an explicit transition from ambiguous/unfinished attempt to the existing review representation, without changing the external HTTP shape. + +### F3: Callback handling must be monotonic by carrier transaction + +- Evidence: every carrier label receives duplicates and reordered pending/active callbacks; the focused reducer test proves the same final state and transaction as one ordered sequence. +- Severity: high. +- Confidence: high. +- Reproduction: `cargo test --all-targets reordered_duplicate_callbacks_are_monotonic`. +- Smallest improvement: make callbacks idempotent by carrier transaction and forbid pending callbacks from downgrading `purchased`. + +### F4: Incomplete journal tails must be repaired before later writes + +- Evidence: skeptical review reproduced a successful first reopen followed by checksum failure after a later append. The corrected regression now completes a second reopen with three valid records. +- Severity: high prototype correctness defect, fixed before archival. +- Confidence: high; reproduced before the fix and covered afterward. +- Reproduction: run `cargo test --all-targets journal_repairs_a_partial_tail_before_later_commits`. +- Smallest improvement: truncate and sync a recognized incomplete final record before permitting another append. + +### F5: Decision history can be verified without retaining the fixture + +- Evidence: the checksummed journal replays and verifies each recorded post-state; 3,478,477 decisions matched. Post-fix one-host runs sampled 11.48–18.20 MiB. The developer observed 293.66 MiB before replacing the retained-history approach, but that discarded implementation was not independently reproduced. +- Severity: high for audit correctness, medium for the initial memory implementation. +- Confidence: high; corruption/partial-tail behavior and full replay are exercised. +- Reproduction: run tests and the measured acceptance fixture. +- Smallest improvement: replay audit history as a stream and retain only the current shipment projection and compact counters. + +### F6: Fold is not the transaction-boundary fix + +- Evidence: public examples demonstrate local transactional views and persistence but no carrier-call/PostgreSQL atomicity, external-authority reconciliation, or review transition. +- Severity: high if forced into the write path because it adds another durable authority. +- Confidence: high for this scoped prototype; production integration details remain unknown. +- Reproduction: compare the public `starter`/`chat` ownership model with the supplied PostgreSQL-authority constraint. +- Smallest improvement: document a workflow-safety example or integration boundary if Fold is intended only for derived, rebuildable views. + +### F7: Public baseline has avoidable first-run friction + +- Evidence: root format check fails in `examples/search`; full tests require an ESE model download; strict Fold clippy reaches existing ANNy warnings. +- Severity: low for this carrier trial, medium for onboarding confidence. +- Confidence: high; exact commands were run on the clean source revision. +- Reproduction: run the three baseline commands in the discovery trail. +- Smallest improvement: format the search example, make ESE test assets explicitly prefetchable or skippable offline, and clear workspace lint warnings. + +## Decision audit + +### Consequential choices + +- Persist intent before network. This is the only local action that makes a later missing response safe to interpret without purchasing again. +- Treat carrier lookup and active callbacks as authoritative evidence of purchase; treat an unknown lookup as review, never proof that purchase did not happen. +- Keep state transitions monotonic and bind all carrier evidence to one carrier transaction ID. +- Store the event and its resulting row state together, then independently reduce the event on reopen. This mirrors a PostgreSQL transaction updating workflow state and inserting history. +- Use a checksummed newline journal and ignore only a final incomplete record, modeling a process exit during a write. +- Use no dependencies. The reliability core needs deterministic state transitions and file durability, not embeddings, nearest-neighbor search, or another database. + +### Rejected alternatives + +- Automatic retry with the merchant request ID: rejected because the brief does not grant a trustworthy carrier idempotency contract. +- Retry after a carrier lookup returns no result: rejected because absence can be stale or inconclusive. +- Fold as workflow authority: rejected because PostgreSQL must remain authoritative and no distributed transaction exists. +- Fold as audit projection inside this prototype: rejected because the audit must prove the PostgreSQL decision history itself; a second store would only prove its own projection. +- Retaining all decoded audit records: rejected after it exceeded the 256 MiB ceiling. +- Modeling refunds: rejected as an explicit non-goal. + +### Remaining uncertainties and limits + +- Carrier lookup semantics vary. Production code must distinguish a conclusive carrier rejection from a missing, stale, or unavailable lookup; this prototype intentionally treats missing evidence as review. +- The journal models the required atomic PostgreSQL row-plus-history transaction but is not a PostgreSQL integration test, because existing database and HTTP shapes were deliberately kept out of scope. +- Actual process exits cover four representative one-shipment boundaries. The 30-seed fixture uses durable close/reopen recovery at four boundaries per seed so it can remain fast and deterministic. +- Peak memory was sampled every 20 milliseconds with `ps`; the large margin below 256 MiB makes sampling error immaterial to the result. +- The simulator is sequential. Database-enforced single-attempt uniqueness or conditional transitions, concurrent workers, conflicting callbacks, callback authentication, real carrier lookup semantics, PostgreSQL, and network faults remain untested. +- Sync evidence covers ordinary process exits after completed local filesystem calls, not kernel failure or power loss. + +After the partial-tail fix, no requested acceptance criterion remains blocked for the sequential reliability-core prototype. Production PostgreSQL, carrier, and concurrency integration remains outside the prototype boundary. diff --git a/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/scripts/measure-acceptance.sh b/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/scripts/measure-acceptance.sh new file mode 100755 index 0000000..edc88d6 --- /dev/null +++ b/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/scripts/measure-acceptance.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 3 ]; then + echo "usage: $0 BINARY RUN_DIRECTORY OUTPUT_LOG" >&2 + exit 2 +fi + +binary=$1 +run_directory=$2 +output_log=$3 + +"$binary" acceptance --dir "$run_directory" --shipments 20000 --seeds 30 >"$output_log" 2>&1 & +worker_pid=$! +peak_rss_kib=0 +sample_count=0 + +while kill -0 "$worker_pid" 2>/dev/null; do + current_rss_kib=$(ps -o rss= -p "$worker_pid" 2>/dev/null | tr -d ' ' || true) + if [ -n "$current_rss_kib" ]; then + sample_count=$((sample_count + 1)) + if [ "$current_rss_kib" -gt "$peak_rss_kib" ]; then + peak_rss_kib=$current_rss_kib + fi + fi + sleep 0.02 +done + +wait "$worker_pid" +cat "$output_log" +if [ "$sample_count" -eq 0 ]; then + echo "MEASUREMENT FAILED: ps returned no valid RSS samples" >&2 + exit 1 +fi +peak_rss_mib=$(awk "BEGIN { printf \"%.2f\", $peak_rss_kib / 1024 }") +echo "MEASURED PEAK RSS: ${peak_rss_kib} KiB (${peak_rss_mib} MiB)" diff --git a/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/src/lib.rs b/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/src/lib.rs new file mode 100644 index 0000000..c37f775 --- /dev/null +++ b/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/src/lib.rs @@ -0,0 +1,1030 @@ +use std::fmt::Write as _; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufRead, BufReader, BufWriter, Write}; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WorkflowState { + Absent, + Created, + Requesting, + Ambiguous, + Purchased, + Failed, + NeedsReview, +} + +impl WorkflowState { + const fn code(self) -> char { + match self { + Self::Absent => '0', + Self::Created => 'C', + Self::Requesting => 'Q', + Self::Ambiguous => 'A', + Self::Purchased => 'P', + Self::Failed => 'F', + Self::NeedsReview => 'R', + } + } + + fn from_code(value: &str) -> Result { + match value { + "0" => Ok(Self::Absent), + "C" => Ok(Self::Created), + "Q" => Ok(Self::Requesting), + "A" => Ok(Self::Ambiguous), + "P" => Ok(Self::Purchased), + "F" => Ok(Self::Failed), + "R" => Ok(Self::NeedsReview), + _ => Err(format!("unknown workflow state {value:?}")), + } + } + + pub const fn is_terminal(self) -> bool { + matches!(self, Self::Purchased | Self::Failed | Self::NeedsReview) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StateRow { + pub state: WorkflowState, + pub carrier_tx: u64, + pub price_cents: u32, + pub attempts: u8, + pub final_at: u32, + pub saw_timeout: bool, +} + +impl Default for StateRow { + fn default() -> Self { + Self { + state: WorkflowState::Absent, + carrier_tx: 0, + price_cents: 0, + attempts: 0, + final_at: 0, + saw_timeout: false, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EventKind { + ShipmentCreated, + AttemptStarted, + PurchaseConfirmed, + PurchaseRejected, + PurchaseTimedOut, + CallbackPending, + CallbackActive, + ReconcileFound, + ReconcileUnknown, +} + +impl EventKind { + const fn code(self) -> char { + match self { + Self::ShipmentCreated => 'C', + Self::AttemptStarted => 'A', + Self::PurchaseConfirmed => 'P', + Self::PurchaseRejected => 'F', + Self::PurchaseTimedOut => 'T', + Self::CallbackPending => 'B', + Self::CallbackActive => 'V', + Self::ReconcileFound => 'R', + Self::ReconcileUnknown => 'U', + } + } + + fn from_code(value: &str) -> Result { + match value { + "C" => Ok(Self::ShipmentCreated), + "A" => Ok(Self::AttemptStarted), + "P" => Ok(Self::PurchaseConfirmed), + "F" => Ok(Self::PurchaseRejected), + "T" => Ok(Self::PurchaseTimedOut), + "B" => Ok(Self::CallbackPending), + "V" => Ok(Self::CallbackActive), + "R" => Ok(Self::ReconcileFound), + "U" => Ok(Self::ReconcileUnknown), + _ => Err(format!("unknown event kind {value:?}")), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Event { + pub kind: EventKind, + pub shipment: u32, + pub at: u32, + pub carrier_tx: u64, + pub price_cents: u32, +} + +impl Event { + pub const fn new(kind: EventKind, shipment: u32, at: u32) -> Self { + Self { + kind, + shipment, + at, + carrier_tx: 0, + price_cents: 0, + } + } + + pub const fn carrier(mut self, tx: u64, price_cents: u32) -> Self { + self.carrier_tx = tx; + self.price_cents = price_cents; + self + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct StoredEvent { + event: Event, + after: StateRow, +} + +fn set_purchased(row: &mut StateRow, event: Event) -> Result<(), String> { + if event.carrier_tx == 0 { + return Err("a purchased decision requires a carrier transaction id".to_string()); + } + if row.carrier_tx != 0 && row.carrier_tx != event.carrier_tx { + return Err(format!( + "shipment {} changed carrier transaction from {} to {}", + event.shipment, row.carrier_tx, event.carrier_tx + )); + } + let was_purchased = row.state == WorkflowState::Purchased; + row.state = WorkflowState::Purchased; + row.carrier_tx = event.carrier_tx; + if event.price_cents != 0 { + row.price_cents = event.price_cents; + } + if !was_purchased { + row.final_at = event.at; + } + Ok(()) +} + +fn reduce(previous: StateRow, event: Event) -> Result { + let mut row = previous; + match event.kind { + EventKind::ShipmentCreated => { + if row.state != WorkflowState::Absent { + return Err(format!("shipment {} was created twice", event.shipment)); + } + row.state = WorkflowState::Created; + } + EventKind::AttemptStarted => { + if row.state != WorkflowState::Created || row.attempts != 0 { + return Err(format!( + "shipment {} attempted purchase from {:?} with {} prior attempts", + event.shipment, row.state, row.attempts + )); + } + row.state = WorkflowState::Requesting; + row.attempts = 1; + } + EventKind::PurchaseConfirmed | EventKind::CallbackActive | EventKind::ReconcileFound => { + set_purchased(&mut row, event)? + } + EventKind::PurchaseRejected => { + if row.state != WorkflowState::Purchased { + row.state = WorkflowState::Failed; + if row.final_at == 0 { + row.final_at = event.at; + } + } + } + EventKind::PurchaseTimedOut => { + row.saw_timeout = true; + if row.state != WorkflowState::Purchased { + row.state = WorkflowState::Ambiguous; + row.final_at = 0; + } + } + EventKind::CallbackPending => { + if row.state == WorkflowState::Absent { + return Err(format!( + "shipment {} received a callback before creation", + event.shipment + )); + } + if row.carrier_tx != 0 && row.carrier_tx != event.carrier_tx { + return Err(format!( + "shipment {} received a conflicting callback transaction", + event.shipment + )); + } + row.carrier_tx = event.carrier_tx; + if event.price_cents != 0 { + row.price_cents = event.price_cents; + } + } + EventKind::ReconcileUnknown => { + if row.state != WorkflowState::Purchased { + row.state = WorkflowState::NeedsReview; + row.final_at = event.at; + } + } + } + Ok(row) +} + +fn checksum(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +fn encode(record: StoredEvent) -> String { + let mut payload = String::with_capacity(96); + write!( + payload, + "1|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}", + record.event.kind.code(), + record.event.shipment, + record.event.at, + record.event.carrier_tx, + record.event.price_cents, + record.after.state.code(), + record.after.carrier_tx, + record.after.price_cents, + record.after.attempts, + record.after.final_at, + u8::from(record.after.saw_timeout) + ) + .expect("writing to a String cannot fail"); + let digest = checksum(payload.as_bytes()); + format!("{payload}|{digest:016x}\n") +} + +fn parse_num(value: &str, name: &str) -> Result { + value + .parse() + .map_err(|_| format!("invalid {name} value {value:?}")) +} + +fn decode(line: &str) -> Result { + let (payload, digest) = line + .rsplit_once('|') + .ok_or_else(|| "journal record has no checksum".to_string())?; + let expected = + u64::from_str_radix(digest, 16).map_err(|_| format!("invalid checksum {digest:?}"))?; + let actual = checksum(payload.as_bytes()); + if expected != actual { + return Err(format!( + "journal checksum mismatch: expected {expected:016x}, got {actual:016x}" + )); + } + let mut fields = payload.split('|'); + let version = fields.next().unwrap_or_default(); + let event_kind = fields.next().unwrap_or_default(); + let shipment = fields.next().unwrap_or_default(); + let event_time = fields.next().unwrap_or_default(); + let event_tx = fields.next().unwrap_or_default(); + let event_price = fields.next().unwrap_or_default(); + let state = fields.next().unwrap_or_default(); + let stored_tx = fields.next().unwrap_or_default(); + let stored_price = fields.next().unwrap_or_default(); + let attempts = fields.next().unwrap_or_default(); + let final_time = fields.next().unwrap_or_default(); + let timeout = fields.next().unwrap_or_default(); + if version != "1" || fields.next().is_some() { + return Err(format!("unsupported journal record {payload:?}")); + } + Ok(StoredEvent { + event: Event { + kind: EventKind::from_code(event_kind)?, + shipment: parse_num(shipment, "shipment")?, + at: parse_num(event_time, "event time")?, + carrier_tx: parse_num(event_tx, "carrier transaction")?, + price_cents: parse_num(event_price, "price")?, + }, + after: StateRow { + state: WorkflowState::from_code(state)?, + carrier_tx: parse_num(stored_tx, "stored carrier transaction")?, + price_cents: parse_num(stored_price, "stored price")?, + attempts: parse_num(attempts, "attempt count")?, + final_at: parse_num(final_time, "final time")?, + saw_timeout: match timeout { + "0" => false, + "1" => true, + value => return Err(format!("invalid timeout marker {value:?}")), + }, + }, + }) +} + +pub struct Journal { + path: PathBuf, + states: Vec, + attempt_records: Vec, + record_count: usize, +} + +impl Journal { + pub fn create(path: &Path, shipments: usize) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .map_err(|error| format!("create {}: {error}", path.display()))?; + Ok(Self { + path: path.to_path_buf(), + states: vec![StateRow::default(); shipments], + attempt_records: vec![0; shipments], + record_count: 0, + }) + } + + pub fn open(path: &Path, shipments: usize) -> Result { + let file = File::open(path).map_err(|error| format!("open {}: {error}", path.display()))?; + let mut states = vec![StateRow::default(); shipments]; + let mut attempt_records = vec![0_u8; shipments]; + let mut record_count = 0; + let mut reader = BufReader::new(file); + let mut bytes = Vec::new(); + let mut valid_len = 0_u64; + loop { + bytes.clear(); + let read = reader + .read_until(b'\n', &mut bytes) + .map_err(|error| format!("read {}: {error}", path.display()))?; + if read == 0 { + break; + } + if !bytes.ends_with(b"\n") { + break; + } + valid_len = valid_len + .checked_add( + u64::try_from(read).map_err(|_| "journal length overflow".to_string())?, + ) + .ok_or_else(|| "journal length overflow".to_string())?; + bytes.pop(); + let line = std::str::from_utf8(&bytes) + .map_err(|error| format!("journal is not UTF-8: {error}"))?; + let record = decode(line)?; + let index = usize::try_from(record.event.shipment) + .map_err(|_| "shipment index overflow".to_string())?; + let previous = *states.get(index).ok_or_else(|| { + format!("shipment {} is outside the fixture", record.event.shipment) + })?; + let reconstructed = reduce(previous, record.event)?; + if reconstructed != record.after { + return Err(format!( + "audit mismatch for shipment {}: reconstructed {reconstructed:?}, stored {:?}", + record.event.shipment, record.after + )); + } + states[index] = record.after; + if record.event.kind == EventKind::AttemptStarted { + attempt_records[index] = attempt_records[index] + .checked_add(1) + .ok_or_else(|| "attempt audit counter overflow".to_string())?; + } + record_count += 1; + } + let file_len = fs::metadata(path) + .map_err(|error| format!("stat {}: {error}", path.display()))? + .len(); + if file_len != valid_len { + let file = OpenOptions::new() + .write(true) + .open(path) + .map_err(|error| format!("repair {}: {error}", path.display()))?; + file.set_len(valid_len) + .map_err(|error| format!("truncate {}: {error}", path.display()))?; + file.sync_data() + .map_err(|error| format!("sync repaired {}: {error}", path.display()))?; + } + Ok(Self { + path: path.to_path_buf(), + states, + attempt_records, + record_count, + }) + } + + pub fn commit_batch(&mut self, events: &[Event]) -> Result<(), String> { + let mut next_states = self.states.clone(); + let mut next_attempt_records = self.attempt_records.clone(); + let mut encoded = String::with_capacity(events.len().saturating_mul(64)); + for event in events { + let index = usize::try_from(event.shipment) + .map_err(|_| "shipment index overflow".to_string())?; + let previous = *next_states + .get(index) + .ok_or_else(|| format!("shipment {} is outside the fixture", event.shipment))?; + let after = reduce(previous, *event)?; + let record = StoredEvent { + event: *event, + after, + }; + encoded.push_str(&encode(record)); + next_states[index] = after; + if event.kind == EventKind::AttemptStarted { + next_attempt_records[index] = next_attempt_records[index] + .checked_add(1) + .ok_or_else(|| "attempt audit counter overflow".to_string())?; + } + } + + let file = OpenOptions::new() + .append(true) + .open(&self.path) + .map_err(|error| format!("append {}: {error}", self.path.display()))?; + let mut writer = BufWriter::new(file); + writer + .write_all(encoded.as_bytes()) + .map_err(|error| format!("write {}: {error}", self.path.display()))?; + writer + .flush() + .map_err(|error| format!("flush {}: {error}", self.path.display()))?; + writer + .get_ref() + .sync_data() + .map_err(|error| format!("sync {}: {error}", self.path.display()))?; + self.states = next_states; + self.attempt_records = next_attempt_records; + self.record_count += events.len(); + Ok(()) + } + + pub fn row(&self, shipment: usize) -> StateRow { + self.states[shipment] + } + + pub fn rows(&self) -> &[StateRow] { + &self.states + } + + pub fn record_count(&self) -> usize { + self.record_count + } + + pub fn attempt_record_count(&self, shipment: usize) -> u8 { + self.attempt_records[shipment] + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct CarrierLabel { + tx: u64, + price_cents: u32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PlannedOutcome { + Confirmed, + Rejected, + TimedOutCharged, + TimedOutUnknown, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PurchaseOutcome { + Confirmed(CarrierLabel), + Rejected, + TimedOut, +} + +fn mix(mut value: u64) -> u64 { + value = value.wrapping_add(0x9e37_79b9_7f4a_7c15); + value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + value ^ (value >> 31) +} + +fn planned_outcome(seed: u64, shipment: u32) -> PlannedOutcome { + if (u64::from(shipment) + seed).is_multiple_of(10) { + if mix(seed.rotate_left(17) ^ u64::from(shipment)) & 1 == 0 { + PlannedOutcome::TimedOutCharged + } else { + PlannedOutcome::TimedOutUnknown + } + } else if mix(seed ^ u64::from(shipment).wrapping_mul(0x9e37_79b9)).is_multiple_of(20) { + PlannedOutcome::Rejected + } else { + PlannedOutcome::Confirmed + } +} + +fn price_for(seed: u64, shipment: u32) -> u32 { + 500 + u32::try_from(mix(seed ^ u64::from(shipment)) % 2_000).expect("bounded price") +} + +struct CarrierSimulator { + seed: u64, + labels: Vec>, + calls: Vec, +} + +impl CarrierSimulator { + fn new(seed: u64, shipments: usize) -> Self { + Self { + seed, + labels: vec![None; shipments], + calls: vec![0; shipments], + } + } + + fn purchase(&mut self, shipment: u32, price_cents: u32) -> Result { + let index = usize::try_from(shipment).map_err(|_| "shipment index overflow".to_string())?; + self.calls[index] = self.calls[index] + .checked_add(1) + .ok_or_else(|| "carrier call counter overflow".to_string())?; + if self.calls[index] != 1 { + return Err(format!( + "unsafe automatic second purchase for shipment {shipment}" + )); + } + let label = CarrierLabel { + tx: self + .seed + .wrapping_mul(1_000_000) + .wrapping_add(u64::from(shipment) + 1), + price_cents, + }; + match planned_outcome(self.seed, shipment) { + PlannedOutcome::Confirmed => { + self.labels[index] = Some(label); + Ok(PurchaseOutcome::Confirmed(label)) + } + PlannedOutcome::Rejected => Ok(PurchaseOutcome::Rejected), + PlannedOutcome::TimedOutCharged => { + self.labels[index] = Some(label); + Ok(PurchaseOutcome::TimedOut) + } + PlannedOutcome::TimedOutUnknown => Ok(PurchaseOutcome::TimedOut), + } + } + + fn lookup(&self, shipment: usize) -> Option { + self.labels[shipment] + } +} + +#[derive(Clone, Debug)] +pub struct SeedMetrics { + pub seed: u64, + pub shipments: usize, + pub purchased: usize, + pub failed: usize, + pub needs_review: usize, + pub ambiguous_timeouts: usize, + pub paid_labels: usize, + pub callbacks: usize, + pub injected_restarts: usize, + pub decision_records: usize, + pub max_final_at: u32, + pub journal_bytes: u64, +} + +fn reopen(journal: Journal, shipments: usize) -> Result { + let path = journal.path.clone(); + drop(journal); + Journal::open(&path, shipments) +} + +fn callback_sequence(seed: u64, shipment: u32, label: CarrierLabel, start_at: u32) -> [Event; 3] { + let pending = Event::new(EventKind::CallbackPending, shipment, start_at) + .carrier(label.tx, label.price_cents); + let active = Event::new(EventKind::CallbackActive, shipment, start_at + 1) + .carrier(label.tx, label.price_cents); + if mix(seed ^ u64::from(shipment)) & 1 == 0 { + [active, pending, active] + } else { + [pending, active, pending] + } +} + +fn find_confirmed(seed: u64, shipments: usize) -> Result { + (0..u32::try_from(shipments).map_err(|_| "fixture is too large".to_string())?) + .find(|shipment| planned_outcome(seed, *shipment) == PlannedOutcome::Confirmed) + .ok_or_else(|| "fixture has no confirmed purchase for the network crash".to_string()) +} + +pub fn run_seed(root: &Path, seed: u64, shipments: usize) -> Result { + if shipments < 10 { + return Err("fixture must contain at least 10 shipments".to_string()); + } + let seed_dir = root.join(format!("seed-{seed:02}")); + fs::create_dir(&seed_dir).map_err(|error| format!("create {}: {error}", seed_dir.display()))?; + let journal_path = seed_dir.join("workflow.journal"); + let mut journal = Journal::create(&journal_path, shipments)?; + let mut carrier = CarrierSimulator::new(seed, shipments); + let mut restarts = 0; + + let shipment_count = + u32::try_from(shipments).map_err(|_| "fixture is too large".to_string())?; + let created: Vec<_> = (0..shipment_count) + .map(|shipment| Event::new(EventKind::ShipmentCreated, shipment, 0)) + .collect(); + journal.commit_batch(&created)?; + + // Crash before attempt persistence: the shipment rows survive and no network call has happened. + journal = reopen(journal, shipments)?; + restarts += 1; + + // Write-ahead intent is durable for every shipment before any carrier request. + let attempts: Vec<_> = (0..shipment_count) + .map(|shipment| Event::new(EventKind::AttemptStarted, shipment, 1)) + .collect(); + journal.commit_batch(&attempts)?; + journal = reopen(journal, shipments)?; + restarts += 1; + + let crash_after_network = find_confirmed(seed, shipments)?; + let mut results = Vec::with_capacity(shipments); + for shipment in 0..shipment_count { + if journal.row(shipment as usize).attempts != 1 { + return Err(format!( + "shipment {shipment} reached the carrier without a durable attempt" + )); + } + let price = price_for(seed, shipment); + let outcome = carrier.purchase(shipment, price)?; + if shipment == crash_after_network { + // The carrier has charged and created the label. The local result is intentionally lost. + journal.commit_batch(&results)?; + results.clear(); + journal = reopen(journal, shipments)?; + restarts += 1; + continue; + } + let event = match outcome { + PurchaseOutcome::Confirmed(label) => { + Event::new(EventKind::PurchaseConfirmed, shipment, 2) + .carrier(label.tx, label.price_cents) + } + PurchaseOutcome::Rejected => Event::new(EventKind::PurchaseRejected, shipment, 2), + PurchaseOutcome::TimedOut => Event::new(EventKind::PurchaseTimedOut, shipment, 2), + }; + results.push(event); + } + journal.commit_batch(&results)?; + + // Crash after the local result commit: terminal rows must resume without another purchase. + journal = reopen(journal, shipments)?; + restarts += 1; + + let mut callbacks = 0; + let mut early_callbacks = Vec::new(); + let mut late_callbacks = Vec::new(); + for shipment in 0..shipment_count { + let Some(label) = carrier.lookup(shipment as usize) else { + continue; + }; + let group = mix(seed.rotate_right(9) ^ u64::from(shipment)) % 3; + let sequence = callback_sequence(seed, shipment, label, if group == 0 { 5 } else { 35 }); + callbacks += sequence.len(); + match group { + 0 => early_callbacks.extend(sequence), + 1 => late_callbacks.extend(sequence), + _ => { + early_callbacks.push( + Event::new(EventKind::CallbackPending, shipment, 5) + .carrier(label.tx, label.price_cents), + ); + early_callbacks.push( + Event::new(EventKind::CallbackPending, shipment, 6) + .carrier(label.tx, label.price_cents), + ); + late_callbacks.push( + Event::new(EventKind::CallbackActive, shipment, 35) + .carrier(label.tx, label.price_cents), + ); + } + } + } + journal.commit_batch(&early_callbacks)?; + + let mut reconciliation = Vec::new(); + for shipment in 0..shipment_count { + let row = journal.row(shipment as usize); + if row.state.is_terminal() { + continue; + } + if let Some(label) = carrier.lookup(shipment as usize) { + reconciliation.push( + Event::new(EventKind::ReconcileFound, shipment, 30) + .carrier(label.tx, label.price_cents), + ); + } else { + reconciliation.push(Event::new(EventKind::ReconcileUnknown, shipment, 30)); + } + } + journal.commit_batch(&reconciliation)?; + journal.commit_batch(&late_callbacks)?; + + // A final reopen is the audit: each stored post-state is checked against a fresh replay. + journal = reopen(journal, shipments)?; + + let mut purchased = 0; + let mut failed = 0; + let mut needs_review = 0; + let mut max_final_at = 0; + let mut ambiguous_timeouts = 0; + let mut paid_labels = 0; + for shipment in 0..shipments { + let row = journal.row(shipment); + if row.attempts != 1 || carrier.calls[shipment] != 1 { + return Err(format!( + "shipment {shipment} did not preserve exactly one durable attempt and one carrier call" + )); + } + if journal.attempt_record_count(shipment) != 1 { + return Err(format!( + "shipment {shipment} audit lost its purchase attempt" + )); + } + let label = carrier.lookup(shipment); + if let Some(label) = label { + paid_labels += 1; + if row.state != WorkflowState::Purchased || row.carrier_tx != label.tx { + return Err(format!( + "shipment {shipment} did not converge to its authoritative carrier label" + )); + } + } else { + match planned_outcome(seed, shipment as u32) { + PlannedOutcome::Rejected => { + if row.state != WorkflowState::Failed { + return Err(format!("shipment {shipment} lost a carrier rejection")); + } + } + PlannedOutcome::TimedOutUnknown => { + if row.state != WorkflowState::NeedsReview || !row.saw_timeout { + return Err(format!( + "shipment {shipment} did not expose an inconclusive timeout for review" + )); + } + } + PlannedOutcome::Confirmed | PlannedOutcome::TimedOutCharged => { + return Err(format!( + "shipment {shipment} is missing an authoritative label" + )); + } + } + } + match row.state { + WorkflowState::Purchased => purchased += 1, + WorkflowState::Failed => failed += 1, + WorkflowState::NeedsReview => needs_review += 1, + other => { + return Err(format!( + "shipment {shipment} remained nonterminal in {other:?}" + )); + } + } + if row.final_at > 60 { + return Err(format!( + "shipment {shipment} converged after the 60-second deadline" + )); + } + max_final_at = max_final_at.max(row.final_at); + if (u64::try_from(shipment).expect("usize fits u64") + seed).is_multiple_of(10) { + ambiguous_timeouts += 1; + if carrier.calls[shipment] != 1 { + return Err(format!("ambiguous shipment {shipment} was purchased twice")); + } + } + } + if ambiguous_timeouts * 10 != shipments { + return Err(format!( + "expected exactly 10% timeouts, got {ambiguous_timeouts}/{shipments}" + )); + } + if purchased + failed + needs_review != shipments { + return Err("terminal-state counts do not cover the fixture".to_string()); + } + + let decision_records = journal.record_count(); + let journal_bytes = fs::metadata(&journal_path) + .map_err(|error| format!("stat {}: {error}", journal_path.display()))? + .len(); + Ok(SeedMetrics { + seed, + shipments, + purchased, + failed, + needs_review, + ambiguous_timeouts, + paid_labels, + callbacks, + injected_restarts: restarts, + decision_records, + max_final_at, + journal_bytes, + }) +} + +pub fn run_fixture(root: &Path, shipments: usize, seeds: u64) -> Result, String> { + fs::create_dir(root).map_err(|error| format!("create {}: {error}", root.display()))?; + let mut metrics = Vec::with_capacity(usize::try_from(seeds).unwrap_or(0)); + for seed in 0..seeds { + metrics.push(run_seed(root, seed, shipments)?); + } + Ok(metrics) +} + +fn write_carrier_label(path: &Path) -> Result { + if path.exists() { + return Err("a second carrier purchase was attempted".to_string()); + } + let label = CarrierLabel { + tx: 424_242, + price_cents: 1_299, + }; + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .map_err(|error| format!("create {}: {error}", path.display()))?; + writeln!(file, "{}|{}", label.tx, label.price_cents) + .map_err(|error| format!("write {}: {error}", path.display()))?; + file.sync_data() + .map_err(|error| format!("sync {}: {error}", path.display()))?; + Ok(label) +} + +fn read_carrier_label(path: &Path) -> Result, String> { + match fs::read_to_string(path) { + Ok(contents) => { + let (tx, price) = contents + .trim() + .split_once('|') + .ok_or_else(|| "invalid carrier authority record".to_string())?; + Ok(Some(CarrierLabel { + tx: parse_num(tx, "carrier transaction")?, + price_cents: parse_num(price, "carrier price")?, + })) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("read {}: {error}", path.display())), + } +} + +pub fn run_crash_child(dir: &Path, stage: &str) -> Result<(), String> { + let journal_path = dir.join("workflow.journal"); + let carrier_path = dir.join("carrier-authority.log"); + let mut journal = Journal::open(&journal_path, 1)?; + match stage { + "before-network" => {} + "after-carrier" => { + write_carrier_label(&carrier_path)?; + } + "after-confirm" => { + let label = write_carrier_label(&carrier_path)?; + journal + .commit_batch(&[Event::new(EventKind::PurchaseConfirmed, 0, 2) + .carrier(label.tx, label.price_cents)])?; + } + "after-callback" => { + let label = write_carrier_label(&carrier_path)?; + journal + .commit_batch(&[Event::new(EventKind::CallbackActive, 0, 3) + .carrier(label.tx, label.price_cents)])?; + } + other => return Err(format!("unknown crash stage {other:?}")), + } + Ok(()) +} + +#[derive(Clone, Debug)] +pub struct CrashMetrics { + pub stage: String, + pub final_state: WorkflowState, + pub carrier_purchases: usize, + pub attempts: u8, +} + +pub fn prepare_crash_scenario(dir: &Path) -> Result<(), String> { + fs::create_dir(dir).map_err(|error| format!("create {}: {error}", dir.display()))?; + let mut journal = Journal::create(&dir.join("workflow.journal"), 1)?; + journal.commit_batch(&[ + Event::new(EventKind::ShipmentCreated, 0, 0), + Event::new(EventKind::AttemptStarted, 0, 1), + ]) +} + +pub fn resume_crash_scenario(dir: &Path, stage: &str) -> Result { + let journal_path = dir.join("workflow.journal"); + let carrier_path = dir.join("carrier-authority.log"); + let mut journal = Journal::open(&journal_path, 1)?; + let label = read_carrier_label(&carrier_path)?; + if !journal.row(0).state.is_terminal() { + let event = match label { + Some(label) => { + Event::new(EventKind::ReconcileFound, 0, 30).carrier(label.tx, label.price_cents) + } + None => Event::new(EventKind::ReconcileUnknown, 0, 30), + }; + journal.commit_batch(&[event])?; + } + let journal = Journal::open(&journal_path, 1)?; + let row = journal.row(0); + let expected = if label.is_some() { + WorkflowState::Purchased + } else { + WorkflowState::NeedsReview + }; + if row.state != expected || row.attempts != 1 { + return Err(format!( + "crash stage {stage} resumed as {:?} with {} attempts", + row.state, row.attempts + )); + } + Ok(CrashMetrics { + stage: stage.to_string(), + final_state: row.state, + carrier_purchases: usize::from(label.is_some()), + attempts: row.attempts, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_path(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + std::env::temp_dir().join(format!( + "carrier-label-ambiguity-{name}-{}-{nonce}", + std::process::id() + )) + } + + #[test] + fn reordered_duplicate_callbacks_are_monotonic() { + let start = StateRow { + state: WorkflowState::Ambiguous, + attempts: 1, + saw_timeout: true, + ..StateRow::default() + }; + let pending = Event::new(EventKind::CallbackPending, 0, 5).carrier(9, 700); + let active = Event::new(EventKind::CallbackActive, 0, 6).carrier(9, 700); + let ordered = reduce(reduce(start, pending).expect("pending"), active).expect("active"); + let reordered = reduce( + reduce(reduce(start, active).expect("active"), pending).expect("pending"), + active, + ) + .expect("duplicate active"); + assert_eq!(ordered.state, WorkflowState::Purchased); + assert_eq!(ordered.state, reordered.state); + assert_eq!(ordered.carrier_tx, reordered.carrier_tx); + } + + #[test] + fn journal_repairs_a_partial_tail_before_later_commits() { + let dir = temp_path("partial-tail"); + fs::create_dir(&dir).expect("temp directory"); + let path = dir.join("workflow.journal"); + let mut journal = Journal::create(&path, 1).expect("create journal"); + journal + .commit_batch(&[ + Event::new(EventKind::ShipmentCreated, 0, 0), + Event::new(EventKind::AttemptStarted, 0, 1), + ]) + .expect("commit"); + let mut file = OpenOptions::new().append(true).open(&path).expect("append"); + file.write_all(b"partial-without-newline") + .expect("partial write"); + file.sync_data().expect("sync partial"); + let mut recovered = Journal::open(&path, 1).expect("recover journal"); + assert_eq!(recovered.row(0).state, WorkflowState::Requesting); + assert_eq!(recovered.record_count(), 2); + recovered + .commit_batch(&[Event::new(EventKind::PurchaseTimedOut, 0, 2)]) + .expect("commit after recovery"); + drop(recovered); + let reopened = Journal::open(&path, 1).expect("reopen repaired journal"); + assert_eq!(reopened.row(0).state, WorkflowState::Ambiguous); + assert_eq!(reopened.record_count(), 3); + fs::remove_dir_all(dir).expect("cleanup"); + } + + #[test] + fn realistic_rules_hold_on_small_fixture() { + let dir = temp_path("small-fixture"); + let metrics = run_fixture(&dir, 100, 3).expect("fixture passes"); + assert_eq!(metrics.len(), 3); + assert!(metrics.iter().all(|seed| seed.ambiguous_timeouts == 10)); + assert!(metrics.iter().all(|seed| seed.injected_restarts == 4)); + fs::remove_dir_all(dir).expect("cleanup"); + } +} diff --git a/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/src/main.rs b/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/src/main.rs new file mode 100644 index 0000000..1f78537 --- /dev/null +++ b/developer-simulation/runs/2026-08-02--carrier-label-ambiguity/src/main.rs @@ -0,0 +1,152 @@ +use std::env; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; +use std::time::Instant; + +use carrier_label_ambiguity::{ + prepare_crash_scenario, resume_crash_scenario, run_crash_child, run_fixture, +}; + +fn usage() -> &'static str { + "usage:\n carrier-label-ambiguity demo --dir PATH\n carrier-label-ambiguity acceptance --dir PATH [--shipments 20000] [--seeds 30]\n carrier-label-ambiguity crash-demo --dir PATH" +} + +fn option(args: &[String], name: &str) -> Result, String> { + let Some(index) = args.iter().position(|arg| arg == name) else { + return Ok(None); + }; + args.get(index + 1) + .cloned() + .ok_or_else(|| format!("{name} requires a value")) + .map(Some) +} + +fn required_dir(args: &[String]) -> Result { + option(args, "--dir")? + .map(PathBuf::from) + .ok_or_else(|| "--dir is required".to_string()) +} + +fn parse_or(args: &[String], name: &str, default: T) -> Result +where + T: std::str::FromStr, +{ + option(args, name)?.map_or(Ok(default), |value| { + value + .parse() + .map_err(|_| format!("invalid {name} value {value:?}")) + }) +} + +fn run_and_print(root: &Path, shipments: usize, seeds: u64) -> Result<(), String> { + let started = Instant::now(); + let metrics = run_fixture(root, shipments, seeds)?; + let mut total_shipments = 0; + let mut total_paid = 0; + let mut total_review = 0; + let mut total_records = 0; + let mut total_bytes = 0; + let mut max_final_at = 0; + for seed in &metrics { + println!( + "seed {:02}: PASS shipments={} purchased={} failed={} needs_review={} ambiguous={} paid_labels={} callbacks={} restarts={} decisions={} max_final_at={}s", + seed.seed, + seed.shipments, + seed.purchased, + seed.failed, + seed.needs_review, + seed.ambiguous_timeouts, + seed.paid_labels, + seed.callbacks, + seed.injected_restarts, + seed.decision_records, + seed.max_final_at + ); + total_shipments += seed.shipments; + total_paid += seed.paid_labels; + total_review += seed.needs_review; + total_records += seed.decision_records; + total_bytes += seed.journal_bytes; + max_final_at = max_final_at.max(seed.max_final_at); + } + println!( + "ACCEPTANCE PASS seeds={} shipments={} paid_labels={} needs_review={} decisions={} max_final_at={}s journal_mib={:.2} elapsed_seconds={:.3}", + metrics.len(), + total_shipments, + total_paid, + total_review, + total_records, + max_final_at, + total_bytes as f64 / 1_048_576.0, + started.elapsed().as_secs_f64() + ); + Ok(()) +} + +fn crash_demo(root: &Path) -> Result<(), String> { + std::fs::create_dir(root).map_err(|error| format!("create {}: {error}", root.display()))?; + let executable = + env::current_exe().map_err(|error| format!("find current executable: {error}"))?; + for stage in [ + "before-network", + "after-carrier", + "after-confirm", + "after-callback", + ] { + let dir = root.join(stage); + prepare_crash_scenario(&dir)?; + let status = Command::new(&executable) + .arg("__crash-child") + .arg("--dir") + .arg(&dir) + .arg("--stage") + .arg(stage) + .status() + .map_err(|error| format!("run crash child: {error}"))?; + if status.code() != Some(86) { + return Err(format!( + "crash child for {stage} exited with {status}, expected code 86" + )); + } + let metrics = resume_crash_scenario(&dir, stage)?; + println!( + "crash {stage}: PASS final={:?} durable_attempts={} carrier_purchases={}", + metrics.final_state, metrics.attempts, metrics.carrier_purchases + ); + } + println!("CRASH/RESTART PASS scenarios=4 automatic_retries=0"); + Ok(()) +} + +fn run() -> Result<(), String> { + let args: Vec = env::args().skip(1).collect(); + let command = args.first().ok_or_else(|| usage().to_string())?; + match command.as_str() { + "demo" => run_and_print(&required_dir(&args)?, 100, 1), + "acceptance" => { + let root = required_dir(&args)?; + let shipments = parse_or(&args, "--shipments", 20_000_usize)?; + let seeds = parse_or(&args, "--seeds", 30_u64)?; + run_and_print(&root, shipments, seeds) + } + "crash-demo" => crash_demo(&required_dir(&args)?), + "__crash-child" => { + let dir = required_dir(&args)?; + let stage = option(&args, "--stage")? + .ok_or_else(|| "--stage is required for crash child".to_string())?; + run_crash_child(&dir, &stage)?; + std::process::exit(86); + } + _ => Err(usage().to_string()), + } +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/developer-simulation/runs/2026-08-02--snapshot-gc-safety/Cargo.toml b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/Cargo.toml new file mode 100644 index 0000000..9364e2e --- /dev/null +++ b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "snapshot-gc-safety" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +serde_json = "1" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +pedantic = "deny" diff --git a/developer-simulation/runs/2026-08-02--snapshot-gc-safety/README.md b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/README.md new file mode 100644 index 0000000..f2c121b --- /dev/null +++ b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/README.md @@ -0,0 +1,113 @@ +# Snapshot GC Safety + +This is a runnable prototype for bounded-memory, recoverable garbage collection of +content-addressed blobs referenced by append-only JSONL snapshot manifests. It is +self-contained and does not modify BogKit or use a BogKit component. + +## What it does + +`plan` validates every committed `manifests/*.jsonl` record before doing anything +destructive. It externally sorts fixed-size binary hashes in small chunks, compares +them with the blob inventory, and commits a named candidate list. + +`apply` takes the publication lock, validates all currently committed manifests +again, and atomically renames only currently unreferenced candidates into the plan's +quarantine. A successful apply deliberately stops there so recovery remains possible. + +`resume` takes the same lock, validates manifests again, restores any quarantined +blob that has become referenced, deletes the remaining quarantined blobs, and writes +a completion marker. The phase markers and filesystem state make all three commands +safe to repeat after ordinary process termination at any mutation boundary. + +`publish` demonstrates the required publisher side of the protocol without changing +the manifest format: write and flush a temporary JSONL file, take the publication +lock, reject an existing final name, restore a referenced blob from quarantine if +necessary, then atomically rename the manifest into its committed `.jsonl` name. +Production publishers must follow this lock-and-rename contract for the concurrency +guarantee to hold. + +Only regular files with a `.jsonl` suffix are treated as committed manifests. Every +record must end in a newline and contain a string `hash` field with exactly 64 hex +characters. A bad committed record reports its manifest and one-based record number. + +## Build and verify + +The only dependency is `serde_json`, used to parse JSON correctly. The commands below +keep generated build output outside this archive-safe directory. + +```sh +cd developer-simulation +export CARGO_TARGET_DIR=/private/tmp/snapshot-gc-safety-target +cargo fmt --manifest-path runs/2026-08-02--snapshot-gc-safety/Cargo.toml -- --check +cargo test -p snapshot-gc-safety --all-targets --offline +cargo clippy -p snapshot-gc-safety --all-targets --offline -- -D warnings +cargo build -p snapshot-gc-safety --release --offline +python3 runs/2026-08-02--snapshot-gc-safety/acceptance.py "$CARGO_TARGET_DIR/release/snapshot-gc-safety" +``` + +The acceptance harness checks 30 differently seeded repositories against an +independent in-memory oracle. It also checks truncated and malformed committed +manifests, a manifest published during planning, publication from quarantine, two +same-name publishers forced to contend for the lock, every quarantine and +finalization crash boundary, and repeated plan/apply/resume calls. + +## Small demonstration + +```sh +DEMO_ROOT="$(mktemp -d /private/tmp/snapshot-gc-demo.XXXXXX)" +BIN="$CARGO_TARGET_DIR/release/snapshot-gc-safety" +"$BIN" fixture "$DEMO_ROOT" --manifests 10 --references 1000 --unique 250 --inventory 300 --missing 3 +"$BIN" plan "$DEMO_ROOT" nightly +"$BIN" verify-plan "$DEMO_ROOT" nightly +"$BIN" apply "$DEMO_ROOT" nightly +"$BIN" status "$DEMO_ROOT" nightly +"$BIN" resume "$DEMO_ROOT" nightly +"$BIN" status "$DEMO_ROOT" nightly +``` + +Expected key lines are `planned 53 candidates`, `oracle match ... zero referenced +blobs selected`, then `quarantined`, and finally `complete`. + +To publish a manifest safely, pass its final name and referenced hashes: + +```sh +"$BIN" publish "$DEMO_ROOT" snapshot-new.jsonl 0000000000000000000000000000000000000000000000000000000000000001 +``` + +## Realistic scale and measurement + +```sh +SCALE_ROOT="$(mktemp -d /private/tmp/snapshot-gc-scale.XXXXXX)" +"$BIN" fixture "$SCALE_ROOT" +python3 runs/2026-08-02--snapshot-gc-safety/measure.py --scratch "$SCALE_ROOT/.snapshot-gc" -- "$BIN" plan "$SCALE_ROOT" realistic +"$BIN" verify-plan "$SCALE_ROOT" realistic +python3 runs/2026-08-02--snapshot-gc-safety/measure.py -- "$BIN" apply "$SCALE_ROOT" realistic +python3 runs/2026-08-02--snapshot-gc-safety/measure.py -- "$BIN" resume "$SCALE_ROOT" realistic +``` + +The default fixture is the requested scale: 10,000 manifests, 1,000,000 reference +records, 250,000 unique hashes, 300,000 zero-byte inventory blobs, 1,000 missing +referenced blobs, duplicates, and 51,000 unreachable inventory blobs. To exercise the +malformed guard, add a committed file without its final newline before `plan`: + +```sh +printf '%s' '{"hash":"abc"}' > "$SCALE_ROOT/manifests/truncated.jsonl" +"$BIN" plan "$SCALE_ROOT" must-abort +``` + +## Frozen comparison baseline + +`baseline.py` is the runnable Python behavior frozen before the BogKit fit decision. +It loads every referenced hash into one Python set and directly unlinks unreferenced +blobs. Its SHA-256 is +`682bc139ce3ed25b16e16daef616014d6be890f0da30eaecd5fb6c9719b27bea`. + +```sh +BASELINE=runs/2026-08-02--snapshot-gc-safety/baseline.py +shasum -a 256 "$BASELINE" +python3 "$BASELINE" seed /private/tmp/snapshot-gc-baseline --references 100000 --unique 25000 --inventory 30000 --manifests 1000 +python3 "$BASELINE" collect /private/tmp/snapshot-gc-baseline +``` + +The detailed observed results, design audit, limitations, and discovery trail are in +`TRIAL_REPORT.md`. diff --git a/developer-simulation/runs/2026-08-02--snapshot-gc-safety/TRIAL_REPORT.md b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/TRIAL_REPORT.md new file mode 100644 index 0000000..4c2ac0c --- /dev/null +++ b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/TRIAL_REPORT.md @@ -0,0 +1,236 @@ +# Snapshot GC Safety Trial Report + +## Outcome + +After skeptical-review correction, the prototype met every prototype acceptance check at the requested scale. Planning +selected all 51,000 eligible unreachable blobs and zero referenced blobs. A committed +truncated or malformed record stopped plan/apply before quarantine and named the file +and record. Cooperative publication during planning and from an existing quarantine +preserved the newly referenced blob. Subprocess exits after every tested quarantine, +finalization, and phase-marker boundary resumed to completion without losing live +blobs. Repeating plan, apply, and resume was harmless. + +Skeptical review reproduced a serious same-name publication race: two cooperative +publishers could both pass the pre-lock existence check, both report success, and the +second rename could replace the first manifest. The final-name check now occurs while +holding the publication lock, temporary output is cleaned on rejection, and a forced- +contention regression requires exactly one publisher to succeed. + +The production integration requirement is explicit: manifest publishers must use the +same advisory publication lock and temporary-file rename protocol demonstrated by the +`publish` command. No collector can close the check-to-rename race against an entirely +uncooperative POSIX writer. Integrating that protocol into the daemon was a stated +non-goal, so it is not attempted here. + +## Ordered discovery and friction trail + +1. Read the public root `README.md`. It described Fold as an incremental materialized + view engine and directed new users to the public examples. +2. Read the public starter, time-series, chat, and search examples in that order. The + useful concepts were atomic transactions and consistent snapshots; none addressed + ownership of external manifest files, publication fencing, quarantine, or bounded + external sorting. +3. Wrote and ran `baseline.py` before inspecting Fold internals. On 100,000 records it + loaded 25,000 unique strings and directly deleted 5,000 blobs in 0.36 seconds. The + file was then frozen at the SHA-256 recorded in the README. +4. Inspected the workspace manifest and Fold's public crate documentation. Fold stores + application-ingested deltas in an embedded LSM database. Using it would require a + second durable copy of external manifest state and would not provide the missing + publisher/quarantine protocol. +5. Chose no BogKit component. Implemented a small standalone Rust package using only + the standard library plus `serde_json` for correct parsing. +6. The first online Cargo check could not resolve `index.crates.io`. The dependency was + already locally available, so all subsequent builds and checks used `--offline`. +7. Strict lint found two ambiguous file-opening/extension checks; both were corrected. +8. The first small demonstration showed per-object directory flushes were unnecessarily + slow. Phase markers retain durability and the state is derivable after process exit, + so directory flushes were moved to phase commits. The crash matrix was rerun after + that change. +9. Ran the 30-repository oracle matrix, malformed guards, concurrency cases, crash + matrix, idempotence checks, and the full million-record fixture. +10. Skeptical review reproduced the full workload and then forced two same-name + publishers to wait behind the same lock. Both originally reported success and the + later rename replaced the earlier manifest. The existence check was moved under + the lock, cleanup was added, and the acceptance harness now requires one success + and one existing-name failure. +11. Review also found that Python baseline memory varied materially across runs. The + direct-deletion and publication-race failures remain proven, but the memory result + is now reported as inadequate and variable headroom rather than a stable breach. + +## Exact validation and observed results + +All commands ran from the `trial-output` directory. Generated Rust output was directed +to `/private/tmp/snapshot-gc-target-trial-b`. + +```text +cargo fmt --all -- --check + PASS + +cargo test --all-targets --offline + 3 passed; 0 failed + +cargo clippy --all-targets --offline -- -D warnings + PASS; zero warnings + +python3 acceptance.py /private/tmp/snapshot-gc-target-trial-b/release/snapshot-gc-safety + acceptance: 30/30 oracle repos, malformed guards, concurrent publication, + all crash boundaries, idempotence: PASS +``` + +The post-fix acceptance includes a forced same-name contention regression that +observed exactly one successful publisher and one existing-name failure. + +Requested-scale fixture creation (developer run): + +```text +fixture: 10000 manifests, 1000000 references, 250000 unique, +300000 inventory, 1000 missing, 51000 unreachable +wall_seconds=17.410, peak_rss_bytes=1,982,464 +manifest corpus logical bytes=76,000,000 +``` + +Requested-scale post-fix plan: + +```text +planned 51000 candidates from 1000000 records and 300000 blobs in 1.475s +measured wall_seconds=1.485 +peak_rss_bytes=6,160,384 +peak_scratch_bytes=38,376,656 +``` + +The corrected plan therefore remained comfortably inside the 90-second, 128 MiB, +and manifest-corpus scratch limits on the measured host. + +Requested-scale independent oracle and mutations: + +```text +oracle match: all 51000 eligible unreachable blobs selected; +zero referenced blobs selected +oracle: independently rechecked all 51,000 candidates + +apply realistic: quarantined 51000 blobs in 6.864s +resume realistic: removed 51000, restored 0 in 2.963s +status: complete +``` + +The oracle intentionally uses an independent in-memory `HashSet`; its memory is not +part of the collector's planning bound. + +Frozen Python baseline on the same requested-scale Rust-generated fixture: + +```text +loaded 250000 unique references; directly removed 51000 blobs +wall_seconds=8.938 +peak_rss_bytes=183,648,256 +``` + +The developer observed one 183,648,256-byte run. Skeptical review reproduced highly +variable one-host results from 124,436,480 to 133,971,968 bytes; the largest was only +245,760 bytes below 128 MiB. This does not establish a stable breach, but it leaves +inadequate production headroom. Independently of memory, the baseline has no +recoverable quarantine or publisher fence and directly deletes candidates. + +## Categorized findings + +### Evidence: Python reference-set memory is variable with inadequate headroom + +- Severity: medium. +- Confidence: high that headroom is inadequate on the measured host; low that it + always breaches the threshold. +- Reproduction: generate the default Rust fixture, then run + `python3 measure.py -- python3 baseline.py collect `. +- Smallest improvement: replace the in-memory string set with fixed-width external + chunk sorting and a streaming merge, and repeat under the production runtime. + +### Evidence: direct deletion has a publication race + +- Severity: critical for data safety. +- Confidence: high; the baseline freezes the manifest list, then directly unlinks. +- Reproduction: publish a committed manifest after its manifest enumeration and before + its inventory deletion. +- Smallest improvement: make publishers write temporary files and take the shared + publication lock for blob validation/resurrection plus the final manifest rename. + +### Prototype correctness defect, fixed: same-name publishers could both succeed + +- Severity: critical for append-only publication safety. +- Confidence: high; skeptical review forced the contention and observed the first + successful manifest being replaced before the fix. +- Reproduction: run `acceptance.py`; its same-name case holds the publication lock + until both publishers have created temporary files and then requires one success + and one failure. +- Smallest improvement: check the final name while holding the shared publication + lock, reject an existing destination, and clean the losing temporary file. + +### Evidence: malformed committed input is fail-closed + +- Severity: critical safeguard. +- Confidence: high; tested before both plan and apply mutations. +- Reproduction: `printf` a JSON object without its final newline into + `manifests/bad.jsonl`, then run plan or apply. +- Smallest improvement: preserve the current file-and-record diagnostic in any daemon + integration and alert on it. + +### Evidence: crash recovery is derived from filesystem state + +- Severity: high. +- Confidence: high for ordinary process termination; the harness injected exit code 86 + after each of four object mutations and the phase marker in both apply and resume. +- Reproduction: set `SNAPSHOT_GC_CRASH_AFTER=1` through `5`, rerun on a four-candidate + fixture, then invoke resume. +- Smallest improvement: add power-loss testing on the production filesystem before + claiming durability against kernel or hardware failure. + +### Evidence: Fold is not a fit for this bounded prototype + +- Severity: medium architecture decision. +- Confidence: high for the stated non-integration prototype. +- Reproduction: compare Fold's public stream/table persistence model with the required + external manifest validation, bounded scratch, publication fence, and quarantine. +- Smallest improvement: no BogKit change is justified by this trial. Re-evaluate only + if Fold gains a bounded external-set primitive that directly owns this protocol. + +## Decision audit + +- Chose fixed 32-byte binary hashes and 65,536-hash sort chunks. This bounds working + memory and makes scratch smaller than the original JSONL corpus. +- Chose a two-step apply/resume flow. A normal successful apply leaves recoverable + quarantine; resume revalidates live references immediately before final deletion. +- Chose an advisory file lock instead of a lock directory. The operating system releases + a file lock when an injected crash exits, so resume never needs unsafe stale-lock theft. +- Chose strict final-newline validation. A partial last JSONL record is treated as a + truncated committed manifest, not a record to ignore. +- Chose to rescan every committed manifest under the publication lock for apply and + resume. A stale plan can over-select, but cannot move a hash that is referenced at + apply time, and cannot finalize a hash referenced at resume time. +- Rejected Fold because it duplicates source state, consumes additional durable scratch, + and does not solve the publication race. +- Rejected SQLite or another embedded database because it adds a dependency and a second + data model when sorted fixed-width files are sufficient. +- Rejected probabilistic filters because zero false negatives are mandatory; false + positives would also prevent selection of every eligible unreachable blob. +- Rejected a grace-period-only design because a new manifest can legitimately reference + an old blob, so age alone cannot prove safety. +- Uncertainty: the prototype tests process exits, not sudden power loss. Directory syncs + protect committed phase markers and publisher ordering, but production filesystems and + mount options need a dedicated power-failure qualification. +- Uncertainty: non-cooperative writers can bypass advisory locks. The CLI refuses to + publish a reference whose blob is neither live nor recoverable, but daemon integration + is required to make every real publisher follow that rule. +- Uncertainty: evidence covers ordinary exits after completed filesystem operations, + not interruption inside a syscall, kernel failure, or power loss. Production also + needs same-filesystem rename, directory-sync, canonical lowercase filename, and + case-sensitive-filesystem qualification. +- Uncertainty: concurrent planners sharing a plan name, publisher crash points, + candidate-file corruption, and an input-independent memory bound remain untested. + +## Files + +- `Cargo.toml` and `Cargo.lock`: standalone package and reproducible dependency lock. +- `src/main.rs`: fixture, plan, apply, resume, status, publisher, oracle, bounded sorter, + validation, and unit tests. +- `acceptance.py`: 30-seed oracle matrix and subprocess safety harness. +- `baseline.py`: frozen Python comparison. +- `measure.py`: wall-time, child RSS, and peak scratch measurement. +- `README.md`: exact reproduction and operating contract. +- `TRIAL_REPORT.md`: this evidence and decision audit. diff --git a/developer-simulation/runs/2026-08-02--snapshot-gc-safety/acceptance.py b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/acceptance.py new file mode 100644 index 0000000..8bf943e --- /dev/null +++ b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/acceptance.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Seeded oracle, malformed-input, concurrency, idempotence, and crash harness.""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +from pathlib import Path +import subprocess +import tempfile +import time + +CRASH_EXIT = 86 + + +def run(binary: Path, *args: object, env: dict[str, str] | None = None, expected: int = 0): + command = [str(binary), *(str(arg) for arg in args)] + result = subprocess.run(command, text=True, capture_output=True, env=env, check=False) + if result.returncode != expected: + raise AssertionError( + f"expected exit {expected}, got {result.returncode}: {' '.join(command)}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result + + +def fixture(binary: Path, root: Path, *, manifests=7, references=400, unique=100, + inventory=125, missing=2) -> None: + run( + binary, + "fixture", + root, + "--manifests", + manifests, + "--references", + references, + "--unique", + unique, + "--inventory", + inventory, + "--missing", + missing, + ) + + +def hash_number(value: int) -> str: + return f"{value:064x}" + + +def assert_live_references(root: Path) -> None: + for manifest in (root / "manifests").glob("*.jsonl"): + with manifest.open(encoding="utf-8") as source: + for line in source: + value = json.loads(line)["hash"] + # Missing blobs are part of the seeded input and stay missing; all initially + # present references and all successfully published references must be live. + numeric = int(value, 16) + if numeric < 98 or manifest.name.startswith("published"): + assert (root / "blobs" / value).is_file(), (manifest, value) + + +def seeded_oracle_matrix(binary: Path, parent: Path) -> None: + for seed in range(30): + root = parent / f"oracle-{seed:02}" + unique = 80 + seed * 3 + missing = seed % 5 + inventory = unique + 20 + (seed % 7) + fixture( + binary, + root, + manifests=5 + seed % 9, + references=unique * 4 + seed, + unique=unique, + inventory=inventory, + missing=missing, + ) + run(binary, "plan", root, "oracle") + result = run(binary, "verify-plan", root, "oracle") + assert "zero referenced blobs selected" in result.stdout + + +def malformed_guards(binary: Path, parent: Path) -> None: + root = parent / "malformed-plan" + fixture(binary, root) + (root / "manifests" / "bad.jsonl").write_text('{"hash":"abc"}', encoding="utf-8") + result = run(binary, "plan", root, "bad", expected=1) + assert "bad.jsonl record 1" in result.stderr + assert not (root / ".snapshot-gc" / "plans" / "bad" / "quarantine").exists() + + root = parent / "malformed-apply" + fixture(binary, root) + run(binary, "plan", root, "bad-apply") + (root / "manifests" / "bad.jsonl").write_text("not-json\n", encoding="utf-8") + result = run(binary, "apply", root, "bad-apply", expected=1) + assert "bad.jsonl record 1" in result.stderr + quarantine = root / ".snapshot-gc" / "plans" / "bad-apply" / "quarantine" + assert not quarantine.exists() or not any(quarantine.iterdir()) + + +def concurrent_publication(binary: Path, parent: Path) -> None: + root = parent / "concurrent-plan" + fixture(binary, root, manifests=20, references=2_000, unique=500, inventory=550, missing=0) + value = hash_number(500) + environment = os.environ.copy() + environment["SNAPSHOT_GC_TEST_PAUSE_BEFORE_CANDIDATES_MS"] = "500" + planner = subprocess.Popen( + [str(binary), "plan", str(root), "race"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=environment, + ) + time.sleep(0.15) + run(binary, "publish", root, "published-during-plan.jsonl", value) + stdout, stderr = planner.communicate(timeout=15) + assert planner.returncode == 0, (stdout, stderr) + candidates = (root / ".snapshot-gc" / "plans" / "race" / "candidates.bin").read_bytes() + assert bytes.fromhex(value) in [candidates[index:index + 32] for index in range(0, len(candidates), 32)] + run(binary, "apply", root, "race") + assert (root / "blobs" / value).is_file() + run(binary, "resume", root, "race") + assert (root / "blobs" / value).is_file() + assert_live_references(root) + + root = parent / "publish-from-quarantine" + fixture(binary, root, unique=100, inventory=104, missing=0) + value = hash_number(100) + run(binary, "plan", root, "resurrect") + run(binary, "apply", root, "resurrect") + assert not (root / "blobs" / value).exists() + run(binary, "publish", root, "published-after-apply.jsonl", value) + assert (root / "blobs" / value).is_file() + run(binary, "resume", root, "resurrect") + assert (root / "blobs" / value).is_file() + + root = parent / "same-name-publishers" + fixture(binary, root, manifests=1, references=2, unique=2, inventory=4, missing=0) + first = hash_number(2) + second = hash_number(3) + lock_path = root / ".snapshot-gc" / "publication.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("a+") as publication_lock: + fcntl.flock(publication_lock, fcntl.LOCK_EX) + publishers = [ + subprocess.Popen( + [str(binary), "publish", str(root), "same.jsonl", value], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + for value in (first, second) + ] + deadline = time.monotonic() + 5 + while len(list((root / "manifests").glob(".same.jsonl.*.tmp"))) != 2: + if time.monotonic() >= deadline: + raise AssertionError("publishers did not both reach the publication lock") + time.sleep(0.01) + fcntl.flock(publication_lock, fcntl.LOCK_UN) + results = [publisher.communicate(timeout=10) for publisher in publishers] + exit_codes = sorted(publisher.returncode for publisher in publishers) + assert exit_codes == [0, 1], (exit_codes, results) + records = [ + json.loads(line)["hash"] + for line in (root / "manifests" / "same.jsonl").read_text(encoding="utf-8").splitlines() + ] + assert records in ([first], [second]), records + + +def crash_matrix(binary: Path, parent: Path) -> None: + for boundary in range(1, 6): + root = parent / f"crash-quarantine-{boundary}" + fixture(binary, root, unique=10, inventory=14, references=40, missing=0) + run(binary, "plan", root, "crash") + environment = os.environ.copy() + environment["SNAPSHOT_GC_CRASH_AFTER"] = str(boundary) + run(binary, "apply", root, "crash", env=environment, expected=CRASH_EXIT) + run(binary, "resume", root, "crash") + assert_live_references(root) + assert run(binary, "status", root, "crash").stdout.strip().endswith("complete") + + for boundary in range(1, 6): + root = parent / f"crash-finalize-{boundary}" + fixture(binary, root, unique=10, inventory=14, references=40, missing=0) + run(binary, "plan", root, "crash") + run(binary, "apply", root, "crash") + environment = os.environ.copy() + environment["SNAPSHOT_GC_CRASH_AFTER"] = str(boundary) + run(binary, "resume", root, "crash", env=environment, expected=CRASH_EXIT) + run(binary, "resume", root, "crash") + assert_live_references(root) + assert run(binary, "status", root, "crash").stdout.strip().endswith("complete") + + +def idempotence(binary: Path, parent: Path) -> None: + root = parent / "idempotent" + fixture(binary, root) + run(binary, "plan", root, "same") + run(binary, "plan", root, "same") + run(binary, "apply", root, "same") + run(binary, "apply", root, "same") + run(binary, "resume", root, "same") + run(binary, "resume", root, "same") + run(binary, "apply", root, "same") + assert_live_references(root) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("binary", type=Path) + args = parser.parse_args() + binary = args.binary.resolve() + with tempfile.TemporaryDirectory(prefix="snapshot-gc-acceptance-") as temporary: + parent = Path(temporary) + seeded_oracle_matrix(binary, parent) + malformed_guards(binary, parent) + concurrent_publication(binary, parent) + crash_matrix(binary, parent) + idempotence(binary, parent) + print("acceptance: 30/30 oracle repos, malformed guards, concurrent publication, all crash boundaries, idempotence: PASS") + + +if __name__ == "__main__": + main() diff --git a/developer-simulation/runs/2026-08-02--snapshot-gc-safety/baseline.py b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/baseline.py new file mode 100755 index 0000000..9b40f10 --- /dev/null +++ b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/baseline.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Frozen comparison baseline: the existing in-memory direct-delete collector.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def object_hash(number: int) -> str: + return f"{number:064x}" + + +def seed(root: Path, references: int, unique: int, inventory: int, manifests: int) -> None: + if unique > inventory or references < unique: + raise ValueError("require references >= unique and inventory >= unique") + manifest_dir = root / "manifests" + blob_dir = root / "blobs" + manifest_dir.mkdir(parents=True, exist_ok=True) + blob_dir.mkdir(parents=True, exist_ok=True) + handles = [ + (manifest_dir / f"snapshot-{index:08}.jsonl").open("w", encoding="utf-8") + for index in range(manifests) + ] + try: + for index in range(references): + value = index if index < unique else index % unique + handles[index % manifests].write(json.dumps({"hash": object_hash(value)}) + "\n") + finally: + for handle in handles: + handle.close() + for index in range(inventory): + (blob_dir / object_hash(index)).touch() + + +def collect(root: Path) -> tuple[int, int]: + # The observed production shape: every reference lives as a Python string + # in one set, and collection deletes immediately after the scan. + referenced: set[str] = set() + manifests = sorted((root / "manifests").glob("*.jsonl")) + for manifest in manifests: + with manifest.open(encoding="utf-8") as source: + for record_number, line in enumerate(source, 1): + try: + record = json.loads(line) + value = record["hash"] + except (json.JSONDecodeError, KeyError, TypeError) as error: + raise RuntimeError( + f"malformed manifest {manifest} record {record_number}: {error}" + ) from error + if not isinstance(value, str) or len(value) != 64: + raise RuntimeError( + f"malformed manifest {manifest} record {record_number}: invalid hash" + ) + referenced.add(value) + + removed = 0 + for blob in (root / "blobs").iterdir(): + if blob.is_file() and blob.name not in referenced: + blob.unlink() + removed += 1 + return len(referenced), removed + + +def main() -> None: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + seed_parser = commands.add_parser("seed") + seed_parser.add_argument("root", type=Path) + seed_parser.add_argument("--references", type=int, default=1_000) + seed_parser.add_argument("--unique", type=int, default=250) + seed_parser.add_argument("--inventory", type=int, default=300) + seed_parser.add_argument("--manifests", type=int, default=10) + collect_parser = commands.add_parser("collect") + collect_parser.add_argument("root", type=Path) + args = parser.parse_args() + + if args.command == "seed": + seed(args.root, args.references, args.unique, args.inventory, args.manifests) + print(f"seeded {args.root}") + else: + referenced, removed = collect(args.root) + print(f"loaded {referenced} unique references; directly removed {removed} blobs") + + +if __name__ == "__main__": + main() diff --git a/developer-simulation/runs/2026-08-02--snapshot-gc-safety/measure.py b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/measure.py new file mode 100644 index 0000000..3732551 --- /dev/null +++ b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/measure.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Run one command and report wall time, peak RSS, and optional peak scratch bytes.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import resource +import subprocess +import sys +import threading +import time + + +def logical_size(root: Path) -> int: + total = 0 + if not root.exists(): + return total + for directory, _subdirectories, files in os.walk(root): + for name in files: + try: + total += (Path(directory) / name).stat().st_size + except FileNotFoundError: + pass + return total + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--scratch", type=Path) + parser.add_argument("command", nargs=argparse.REMAINDER) + args = parser.parse_args() + command = args.command + if command and command[0] == "--": + command = command[1:] + if not command: + parser.error("a command is required after --") + + peak_scratch = 0 + stop = threading.Event() + + def monitor() -> None: + nonlocal peak_scratch + if args.scratch is None: + return + while not stop.wait(0.01): + peak_scratch = max(peak_scratch, logical_size(args.scratch)) + + watcher = threading.Thread(target=monitor, daemon=True) + watcher.start() + started = time.monotonic() + result = subprocess.run(command, check=False) + wall_seconds = time.monotonic() - started + stop.set() + watcher.join() + if args.scratch is not None: + peak_scratch = max(peak_scratch, logical_size(args.scratch)) + + peak_rss = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss + if sys.platform.startswith("linux"): + peak_rss *= 1024 + print( + json.dumps( + { + "exit": result.returncode, + "wall_seconds": round(wall_seconds, 3), + "peak_rss_bytes": peak_rss, + "peak_scratch_bytes": peak_scratch, + }, + sort_keys=True, + ) + ) + raise SystemExit(result.returncode) + + +if __name__ == "__main__": + main() diff --git a/developer-simulation/runs/2026-08-02--snapshot-gc-safety/src/main.rs b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/src/main.rs new file mode 100644 index 0000000..eea7ab5 --- /dev/null +++ b/developer-simulation/runs/2026-08-02--snapshot-gc-safety/src/main.rs @@ -0,0 +1,955 @@ +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashSet}; +use std::env; +use std::error::Error; +use std::fmt::Write as _; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufRead, BufReader, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process; +use std::thread; +use std::time::{Duration, Instant}; + +type AnyResult = Result>; +type Hash = [u8; 32]; + +const CHUNK_HASHES: usize = 65_536; +const CRASH_EXIT: i32 = 86; + +fn main() { + if let Err(error) = run() { + eprintln!("error: {error}"); + process::exit(1); + } +} + +fn run() -> AnyResult<()> { + let mut args = env::args().skip(1); + let command = args.next().ok_or_else(usage)?; + match command.as_str() { + "fixture" => { + let root = required_path(&mut args, "fixture root")?; + let config = FixtureConfig::parse(args)?; + fixture(&root, &config) + } + "plan" => { + let root = required_path(&mut args, "repository root")?; + let plan = required_arg(&mut args, "plan name")?; + no_extra(args)?; + create_plan(&root, &plan) + } + "apply" => { + let root = required_path(&mut args, "repository root")?; + let plan = required_arg(&mut args, "plan name")?; + no_extra(args)?; + apply(&root, &plan) + } + "resume" => { + let root = required_path(&mut args, "repository root")?; + let plan = required_arg(&mut args, "plan name")?; + no_extra(args)?; + resume(&root, &plan) + } + "verify-plan" => { + let root = required_path(&mut args, "repository root")?; + let plan = required_arg(&mut args, "plan name")?; + no_extra(args)?; + verify_plan(&root, &plan) + } + "publish" => { + let root = required_path(&mut args, "repository root")?; + let manifest = required_arg(&mut args, "manifest file name")?; + let hashes: Vec = args.collect(); + publish(&root, &manifest, &hashes) + } + "status" => { + let root = required_path(&mut args, "repository root")?; + let plan = required_arg(&mut args, "plan name")?; + no_extra(args)?; + status(&root, &plan) + } + _ => Err(usage().into()), + } +} + +fn usage() -> String { + "usage: snapshot-gc-safety ..." + .to_owned() +} + +fn required_arg(args: &mut impl Iterator, label: &str) -> AnyResult { + args.next().ok_or_else(|| format!("missing {label}").into()) +} + +fn required_path(args: &mut impl Iterator, label: &str) -> AnyResult { + Ok(PathBuf::from(required_arg(args, label)?)) +} + +fn no_extra(mut args: impl Iterator) -> AnyResult<()> { + if let Some(extra) = args.next() { + Err(format!("unexpected argument: {extra}").into()) + } else { + Ok(()) + } +} + +#[derive(Debug)] +struct FixtureConfig { + manifests: usize, + references: usize, + unique: usize, + inventory: usize, + missing: usize, +} + +impl Default for FixtureConfig { + fn default() -> Self { + Self { + manifests: 10_000, + references: 1_000_000, + unique: 250_000, + inventory: 300_000, + missing: 1_000, + } + } +} + +impl FixtureConfig { + fn parse(mut args: impl Iterator) -> AnyResult { + let mut config = Self::default(); + while let Some(flag) = args.next() { + let value = args + .next() + .ok_or_else(|| format!("missing value for {flag}"))? + .parse::()?; + match flag.as_str() { + "--manifests" => config.manifests = value, + "--references" => config.references = value, + "--unique" => config.unique = value, + "--inventory" => config.inventory = value, + "--missing" => config.missing = value, + _ => return Err(format!("unknown fixture flag: {flag}").into()), + } + } + if config.manifests == 0 + || config.unique == 0 + || config.references < config.unique + || config.inventory < config.unique - config.missing + || config.missing > config.unique + { + return Err("invalid fixture dimensions".into()); + } + Ok(config) + } +} + +fn fixture(root: &Path, config: &FixtureConfig) -> AnyResult<()> { + if root.exists() && fs::read_dir(root)?.next().is_some() { + return Err(format!("fixture root must be absent or empty: {}", root.display()).into()); + } + let manifests = root.join("manifests"); + let blobs = root.join("blobs"); + fs::create_dir_all(&manifests)?; + fs::create_dir_all(&blobs)?; + + let per_manifest = config.references.div_ceil(config.manifests); + let mut record = 0_usize; + for manifest_number in 0..config.manifests { + let path = manifests.join(format!("snapshot-{manifest_number:08}.jsonl")); + let mut output = BufWriter::new(File::create(path)?); + for _ in 0..per_manifest { + if record == config.references { + break; + } + let object = if record < config.unique { + record + } else { + record % config.unique + }; + writeln!(output, "{{\"hash\":\"{}\"}}", hash_number(object))?; + record += 1; + } + } + + let referenced_present = config.unique - config.missing; + for object in 0..referenced_present { + File::create(blobs.join(hash_number(object)))?; + } + let unreachable = config.inventory - referenced_present; + for offset in 0..unreachable { + File::create(blobs.join(hash_number(config.unique + offset)))?; + } + sync_directory(&manifests)?; + sync_directory(&blobs)?; + println!( + "fixture: {} manifests, {} references, {} unique, {} inventory, {} missing, {} unreachable", + config.manifests, + config.references, + config.unique, + config.inventory, + config.missing, + unreachable + ); + Ok(()) +} + +fn hash_number(value: usize) -> String { + format!("{value:064x}") +} + +fn state_root(root: &Path) -> PathBuf { + root.join(".snapshot-gc") +} + +fn plan_dir(root: &Path, plan: &str) -> AnyResult { + validate_name(plan, "plan")?; + Ok(state_root(root).join("plans").join(plan)) +} + +fn validate_name(name: &str, kind: &str) -> AnyResult<()> { + if name.is_empty() + || name.len() > 100 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + || name == "." + || name == ".." + { + return Err(format!("invalid {kind} name: {name}").into()); + } + Ok(()) +} + +fn create_plan(root: &Path, plan: &str) -> AnyResult<()> { + let started = Instant::now(); + validate_repository(root)?; + let final_dir = plan_dir(root, plan)?; + if final_dir.join("meta.json").is_file() { + println!("plan {plan} already exists; unchanged"); + return Ok(()); + } + let plans = final_dir + .parent() + .ok_or("plan path has no parent")? + .to_path_buf(); + fs::create_dir_all(&plans)?; + let building = plans.join(format!(".{plan}.building")); + if building.exists() { + fs::remove_dir_all(&building)?; + } + fs::create_dir(&building)?; + + let result = build_plan(root, plan, &building, started); + if let Err(error) = result { + let _ = fs::remove_dir_all(&building); + return Err(error); + } + fs::rename(&building, &final_dir)?; + sync_directory(&plans)?; + println!("plan {plan} committed at {}", final_dir.display()); + Ok(()) +} + +fn build_plan(root: &Path, plan: &str, building: &Path, started: Instant) -> AnyResult<()> { + let manifests = committed_manifests(root)?; + let mut manifest_set = BufWriter::new(File::create(building.join("manifest-set.txt"))?); + for manifest in &manifests { + let metadata = fs::metadata(manifest)?; + writeln!( + manifest_set, + "{}\t{}", + metadata.len(), + manifest + .file_name() + .ok_or("manifest has no file name")? + .to_string_lossy() + )?; + } + manifest_set.flush()?; + + let references_path = building.join("references.bin"); + let reference_records = build_references(&manifests, building, &references_path)?; + + if let Ok(value) = env::var("SNAPSHOT_GC_TEST_PAUSE_BEFORE_CANDIDATES_MS") { + thread::sleep(Duration::from_millis(value.parse()?)); + } + + let inventory_path = building.join("inventory.bin"); + let inventory_count = build_inventory(root, building, &inventory_path)?; + let candidates_path = building.join("candidates.bin"); + let (unique_references, candidates) = + sorted_difference(&inventory_path, &references_path, &candidates_path)?; + fs::remove_file(inventory_path)?; + + let meta = serde_json::json!({ + "version": 1, + "plan": plan, + "manifests": manifests.len(), + "reference_records": reference_records, + "unique_references": unique_references, + "inventory": inventory_count, + "candidates": candidates, + "elapsed_ms": started.elapsed().as_millis(), + }); + let mut meta_file = File::create(building.join("meta.json"))?; + serde_json::to_writer_pretty(&mut meta_file, &meta)?; + writeln!(meta_file)?; + meta_file.sync_all()?; + sync_directory(building)?; + println!( + "planned {candidates} candidates from {reference_records} records and {inventory_count} blobs in {:.3}s", + started.elapsed().as_secs_f64() + ); + Ok(()) +} + +fn validate_repository(root: &Path) -> AnyResult<()> { + for name in ["manifests", "blobs"] { + let path = root.join(name); + if !path.is_dir() { + return Err(format!("missing directory: {}", path.display()).into()); + } + } + Ok(()) +} + +fn committed_manifests(root: &Path) -> AnyResult> { + let mut paths = Vec::new(); + for entry in fs::read_dir(root.join("manifests"))? { + let entry = entry?; + let file_type = entry.file_type()?; + if file_type.is_file() + && entry + .path() + .extension() + .is_some_and(|value| value == "jsonl") + { + paths.push(entry.path()); + } + } + paths.sort(); + Ok(paths) +} + +fn build_references(manifests: &[PathBuf], scratch: &Path, output: &Path) -> AnyResult { + let chunks = scratch.join("reference-chunks"); + let mut sorter = HashSorter::new(&chunks)?; + let mut records = 0_u64; + for manifest in manifests { + let before = fs::metadata(manifest)?; + let source = File::open(manifest)?; + let mut reader = BufReader::new(source); + let mut line = Vec::new(); + let mut record_number = 0_u64; + loop { + line.clear(); + let bytes = reader.read_until(b'\n', &mut line)?; + if bytes == 0 { + break; + } + record_number += 1; + if line.last() != Some(&b'\n') { + return Err(malformed(manifest, record_number, "truncated JSONL record")); + } + line.pop(); + if line.last() == Some(&b'\r') { + line.pop(); + } + let value: serde_json::Value = serde_json::from_slice(&line) + .map_err(|error| malformed(manifest, record_number, &error.to_string()))?; + let hash = value + .as_object() + .and_then(|object| object.get("hash")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| malformed(manifest, record_number, "missing string field `hash`"))?; + sorter.push(parse_hash(hash).map_err(|error| { + malformed(manifest, record_number, &format!("invalid hash: {error}")) + })?)?; + records += 1; + } + let after = fs::metadata(manifest)?; + if before.len() != after.len() || before.modified()? != after.modified()? { + return Err(malformed( + manifest, + record_number.max(1), + "manifest changed while being read", + )); + } + } + sorter.finish(output)?; + Ok(records) +} + +fn malformed(path: &Path, record: u64, detail: &str) -> Box { + format!( + "malformed committed manifest {} record {record}: {detail}", + path.display() + ) + .into() +} + +fn build_inventory(root: &Path, scratch: &Path, output: &Path) -> AnyResult { + let chunks = scratch.join("inventory-chunks"); + let mut sorter = HashSorter::new(&chunks)?; + let mut count = 0_u64; + for entry in fs::read_dir(root.join("blobs"))? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if let Ok(hash) = parse_hash(name) { + sorter.push(hash)?; + count += 1; + } + } + sorter.finish(output)?; + Ok(count) +} + +struct HashSorter { + directory: PathBuf, + buffer: Vec, + chunks: Vec, +} + +impl HashSorter { + fn new(directory: &Path) -> AnyResult { + if directory.exists() { + fs::remove_dir_all(directory)?; + } + fs::create_dir(directory)?; + Ok(Self { + directory: directory.to_path_buf(), + buffer: Vec::with_capacity(CHUNK_HASHES), + chunks: Vec::new(), + }) + } + + fn push(&mut self, hash: Hash) -> AnyResult<()> { + self.buffer.push(hash); + if self.buffer.len() == CHUNK_HASHES { + self.flush()?; + } + Ok(()) + } + + fn flush(&mut self) -> AnyResult<()> { + if self.buffer.is_empty() { + return Ok(()); + } + self.buffer.sort_unstable(); + self.buffer.dedup(); + let path = self + .directory + .join(format!("chunk-{:08}.bin", self.chunks.len())); + let mut output = BufWriter::new(File::create(&path)?); + for hash in &self.buffer { + output.write_all(hash)?; + } + output.flush()?; + self.chunks.push(path); + self.buffer.clear(); + Ok(()) + } + + fn finish(mut self, output: &Path) -> AnyResult { + self.flush()?; + let mut readers: Vec> = self + .chunks + .iter() + .map(File::open) + .collect::>>()? + .into_iter() + .map(BufReader::new) + .collect(); + let mut heap = BinaryHeap::new(); + for (index, reader) in readers.iter_mut().enumerate() { + if let Some(hash) = read_hash(reader)? { + heap.push(Reverse((hash, index))); + } + } + let mut destination = BufWriter::new(File::create(output)?); + let mut last = None; + let mut count = 0_u64; + while let Some(Reverse((hash, index))) = heap.pop() { + if last != Some(hash) { + destination.write_all(&hash)?; + last = Some(hash); + count += 1; + } + if let Some(next) = read_hash(&mut readers[index])? { + heap.push(Reverse((next, index))); + } + } + destination.flush()?; + destination.get_ref().sync_all()?; + drop(destination); + drop(readers); + fs::remove_dir_all(&self.directory)?; + Ok(count) + } +} + +fn read_hash(reader: &mut impl Read) -> io::Result> { + let mut hash = [0_u8; 32]; + let mut read = 0; + while read < hash.len() { + let bytes = reader.read(&mut hash[read..])?; + if bytes == 0 { + if read == 0 { + return Ok(None); + } + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "partial binary hash", + )); + } + read += bytes; + } + Ok(Some(hash)) +} + +fn sorted_difference(inventory: &Path, references: &Path, output: &Path) -> AnyResult<(u64, u64)> { + let mut inventory = BufReader::new(File::open(inventory)?); + let mut references = BufReader::new(File::open(references)?); + let mut output = BufWriter::new(File::create(output)?); + let mut reference = read_hash(&mut references)?; + let mut unique_references = u64::from(reference.is_some()); + let mut candidates = 0_u64; + while let Some(hash) = read_hash(&mut inventory)? { + while reference.is_some_and(|value| value < hash) { + reference = read_hash(&mut references)?; + unique_references += u64::from(reference.is_some()); + } + if reference != Some(hash) { + output.write_all(&hash)?; + candidates += 1; + } + } + while read_hash(&mut references)?.is_some() { + unique_references += 1; + } + output.flush()?; + output.get_ref().sync_all()?; + Ok((unique_references, candidates)) +} + +fn parse_hash(value: &str) -> AnyResult { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("expected exactly 64 hexadecimal characters".into()); + } + let mut hash = [0_u8; 32]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + hash[index] = (hex_value(pair[0])? << 4) | hex_value(pair[1])?; + } + Ok(hash) +} + +fn hex_value(byte: u8) -> AnyResult { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => Err("non-hexadecimal character".into()), + } +} + +fn display_hash(hash: &Hash) -> String { + let mut value = String::with_capacity(64); + for byte in hash { + write!(value, "{byte:02x}").expect("writing into a String cannot fail"); + } + value +} + +fn lock_publication(root: &Path) -> AnyResult { + let state = state_root(root); + fs::create_dir_all(&state)?; + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(state.join("publication.lock"))?; + lock.lock()?; + Ok(lock) +} + +fn apply(root: &Path, plan: &str) -> AnyResult<()> { + validate_repository(root)?; + let directory = require_plan(root, plan)?; + if directory.join("complete").is_file() { + println!("plan {plan} is already complete; unchanged"); + return Ok(()); + } + if directory.join("quarantined").is_file() { + println!("plan {plan} is already quarantined; run resume to finalize"); + return Ok(()); + } + quarantine_phase( + root, + plan, + &directory, + &mut CrashInjector::from_environment(), + ) +} + +fn resume(root: &Path, plan: &str) -> AnyResult<()> { + validate_repository(root)?; + let directory = require_plan(root, plan)?; + if directory.join("complete").is_file() { + println!("plan {plan} is already complete; unchanged"); + return Ok(()); + } + let mut crashes = CrashInjector::from_environment(); + if !directory.join("quarantined").is_file() { + quarantine_phase(root, plan, &directory, &mut crashes)?; + } + finalize_phase(root, plan, &directory, &mut crashes) +} + +fn require_plan(root: &Path, plan: &str) -> AnyResult { + let directory = plan_dir(root, plan)?; + if !directory.join("meta.json").is_file() || !directory.join("candidates.bin").is_file() { + return Err(format!("plan {plan} does not exist or is incomplete").into()); + } + Ok(directory) +} + +fn quarantine_phase( + root: &Path, + plan: &str, + directory: &Path, + crashes: &mut CrashInjector, +) -> AnyResult<()> { + let started = Instant::now(); + let _lock = lock_publication(root)?; + let live = rebuild_live_references(root, directory)?; + let quarantine = directory.join("quarantine"); + fs::create_dir_all(&quarantine)?; + let mut candidates = BufReader::new(File::open(directory.join("candidates.bin"))?); + let mut references = SortedMembership::new(&live)?; + let mut moved = 0_u64; + while let Some(hash) = read_hash(&mut candidates)? { + let source = root.join("blobs").join(display_hash(&hash)); + let target = quarantine.join(display_hash(&hash)); + if references.contains(hash)? { + if target.is_file() && !source.exists() { + fs::rename(&target, &source)?; + crashes.boundary("quarantine-live-restore"); + } + } else if source.is_file() && !target.exists() { + fs::rename(&source, &target)?; + moved += 1; + crashes.boundary("quarantine-move"); + } + } + write_marker(&directory.join("quarantined"), &format!("moved={moved}\n"))?; + crashes.boundary("quarantine-marker"); + println!( + "apply {plan}: quarantined {moved} blobs in {:.3}s; run resume to finalize", + started.elapsed().as_secs_f64() + ); + Ok(()) +} + +fn finalize_phase( + root: &Path, + plan: &str, + directory: &Path, + crashes: &mut CrashInjector, +) -> AnyResult<()> { + let started = Instant::now(); + let _lock = lock_publication(root)?; + let live = rebuild_live_references(root, directory)?; + let quarantine = directory.join("quarantine"); + fs::create_dir_all(&quarantine)?; + let mut candidates = BufReader::new(File::open(directory.join("candidates.bin"))?); + let mut references = SortedMembership::new(&live)?; + let mut removed = 0_u64; + let mut restored = 0_u64; + while let Some(hash) = read_hash(&mut candidates)? { + let source = quarantine.join(display_hash(&hash)); + if !source.is_file() { + continue; + } + let live_blob = root.join("blobs").join(display_hash(&hash)); + if references.contains(hash)? { + if !live_blob.exists() { + fs::rename(&source, &live_blob)?; + restored += 1; + crashes.boundary("finalize-live-restore"); + } + } else { + fs::remove_file(&source)?; + removed += 1; + crashes.boundary("finalize-remove"); + } + } + write_marker( + &directory.join("complete"), + &format!("removed={removed}\nrestored={restored}\n"), + )?; + crashes.boundary("complete-marker"); + println!( + "resume {plan}: removed {removed}, restored {restored} in {:.3}s", + started.elapsed().as_secs_f64() + ); + Ok(()) +} + +fn rebuild_live_references(root: &Path, directory: &Path) -> AnyResult { + let manifests = committed_manifests(root)?; + let scratch = directory.join("live-build"); + if scratch.exists() { + fs::remove_dir_all(&scratch)?; + } + fs::create_dir(&scratch)?; + let output = directory.join("live-references.bin"); + let result = build_references(&manifests, &scratch, &output); + let _ = fs::remove_dir_all(&scratch); + result?; + Ok(output) +} + +struct SortedMembership { + reader: BufReader, + current: Option, +} + +impl SortedMembership { + fn new(path: &Path) -> AnyResult { + let mut reader = BufReader::new(File::open(path)?); + let current = read_hash(&mut reader)?; + Ok(Self { reader, current }) + } + + fn contains(&mut self, wanted: Hash) -> AnyResult { + while self.current.is_some_and(|value| value < wanted) { + self.current = read_hash(&mut self.reader)?; + } + Ok(self.current == Some(wanted)) + } +} + +struct CrashInjector { + crash_after: Option, + boundaries: u64, +} + +impl CrashInjector { + fn from_environment() -> Self { + let crash_after = env::var("SNAPSHOT_GC_CRASH_AFTER") + .ok() + .and_then(|value| value.parse().ok()); + Self { + crash_after, + boundaries: 0, + } + } + + fn boundary(&mut self, label: &str) { + self.boundaries += 1; + if self.crash_after == Some(self.boundaries) { + eprintln!( + "injected crash after boundary {} ({label})", + self.boundaries + ); + process::exit(CRASH_EXIT); + } + } +} + +fn write_marker(path: &Path, contents: &str) -> AnyResult<()> { + let file_name = path.file_name().ok_or("marker has no file name")?; + let temporary = path.with_file_name(format!(".{}.tmp", file_name.to_string_lossy())); + let mut file = File::create(&temporary)?; + file.write_all(contents.as_bytes())?; + file.sync_all()?; + fs::rename(&temporary, path)?; + sync_directory(path.parent().ok_or("marker has no parent")?)?; + Ok(()) +} + +fn publish(root: &Path, manifest: &str, values: &[String]) -> AnyResult<()> { + validate_repository(root)?; + validate_name(manifest, "manifest")?; + if Path::new(manifest) + .extension() + .is_none_or(|value| value != "jsonl") + { + return Err("published manifest name must end in .jsonl".into()); + } + if values.is_empty() { + return Err("publish requires at least one hash".into()); + } + let hashes: Vec = values + .iter() + .map(|value| parse_hash(value)) + .collect::>>()?; + let manifests = root.join("manifests"); + let final_path = manifests.join(manifest); + let temporary = manifests.join(format!(".{manifest}.{}.tmp", process::id())); + let mut output = BufWriter::new(File::create(&temporary)?); + for value in values { + writeln!(output, "{{\"hash\":\"{}\"}}", value.to_ascii_lowercase())?; + } + output.flush()?; + output.get_ref().sync_all()?; + + let _lock = lock_publication(root)?; + if final_path.exists() { + let _ = fs::remove_file(&temporary); + return Err(format!("manifest already exists: {}", final_path.display()).into()); + } + for hash in hashes { + ensure_blob_available(root, &hash)?; + } + fs::rename(&temporary, &final_path)?; + sync_directory(&manifests)?; + println!("published {}", final_path.display()); + Ok(()) +} + +fn ensure_blob_available(root: &Path, hash: &Hash) -> AnyResult<()> { + let name = display_hash(hash); + let live = root.join("blobs").join(&name); + if live.is_file() { + return Ok(()); + } + let plans = state_root(root).join("plans"); + if plans.is_dir() { + for entry in fs::read_dir(plans)? { + let candidate = entry?.path().join("quarantine").join(&name); + if candidate.is_file() { + fs::rename(&candidate, &live)?; + sync_directory(candidate.parent().ok_or("quarantine has no parent")?)?; + sync_directory(&root.join("blobs"))?; + return Ok(()); + } + } + } + Err(format!("cannot publish reference to unavailable blob {name}").into()) +} + +fn verify_plan(root: &Path, plan: &str) -> AnyResult<()> { + validate_repository(root)?; + let directory = require_plan(root, plan)?; + let mut referenced = HashSet::new(); + for manifest in committed_manifests(root)? { + load_manifest_for_oracle(&manifest, &mut referenced)?; + } + let mut planned = HashSet::new(); + let mut reader = BufReader::new(File::open(directory.join("candidates.bin"))?); + while let Some(hash) = read_hash(&mut reader)? { + planned.insert(hash); + } + let mut eligible = 0_usize; + for entry in fs::read_dir(root.join("blobs"))? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { + continue; + }; + let Ok(hash) = parse_hash(&name) else { + continue; + }; + if !referenced.contains(&hash) { + eligible += 1; + if !planned.remove(&hash) { + return Err( + format!("oracle mismatch: eligible blob {name} is absent from plan").into(), + ); + } + } else if planned.contains(&hash) { + return Err(format!("oracle mismatch: referenced blob {name} is in plan").into()); + } + } + if let Some(extra) = planned.iter().next() { + return Err(format!( + "oracle mismatch: non-inventory candidate {}", + display_hash(extra) + ) + .into()); + } + println!( + "oracle match: all {eligible} eligible unreachable blobs selected; zero referenced blobs selected" + ); + Ok(()) +} + +fn load_manifest_for_oracle(path: &Path, referenced: &mut HashSet) -> AnyResult<()> { + let mut reader = BufReader::new(File::open(path)?); + let mut line = String::new(); + let mut record = 0_u64; + loop { + line.clear(); + let bytes = reader.read_line(&mut line)?; + if bytes == 0 { + break; + } + record += 1; + if !line.ends_with('\n') { + return Err(malformed(path, record, "truncated JSONL record")); + } + let value: serde_json::Value = serde_json::from_str(&line) + .map_err(|error| malformed(path, record, &error.to_string()))?; + let hash = value + .get("hash") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| malformed(path, record, "missing string field `hash`"))?; + referenced.insert(parse_hash(hash)?); + } + Ok(()) +} + +fn status(root: &Path, plan: &str) -> AnyResult<()> { + let directory = require_plan(root, plan)?; + let phase = if directory.join("complete").is_file() { + "complete" + } else if directory.join("quarantined").is_file() { + "quarantined" + } else { + "planned" + }; + println!("plan {plan}: {phase}"); + Ok(()) +} + +fn sync_directory(path: &Path) -> io::Result<()> { + File::open(path)?.sync_all() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_round_trip_and_case_normalization() { + let input = "0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789ABCDEF"; + let parsed = parse_hash(input).unwrap(); + assert_eq!( + display_hash(&parsed), + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ); + } + + #[test] + fn rejects_wrong_hash_shapes() { + assert!(parse_hash("abc").is_err()); + assert!(parse_hash(&"z".repeat(64)).is_err()); + } + + #[test] + fn validates_names() { + assert!(validate_name("nightly-2026_08.02", "plan").is_ok()); + assert!(validate_name("../escape", "plan").is_err()); + assert!(validate_name("", "plan").is_err()); + } +} diff --git a/developer-simulation/runs/2026-08-03--offline-door-policy-update/Cargo.toml b/developer-simulation/runs/2026-08-03--offline-door-policy-update/Cargo.toml new file mode 100644 index 0000000..4b4c703 --- /dev/null +++ b/developer-simulation/runs/2026-08-03--offline-door-policy-update/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "offline-door-policy-fit-probe" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +fold = { path = "../../../fold" } +libc = "0.2" +serde = { version = "1", features = ["derive"] } + +[lints.clippy] +all = "deny" diff --git a/developer-simulation/runs/2026-08-03--offline-door-policy-update/README.md b/developer-simulation/runs/2026-08-03--offline-door-policy-update/README.md new file mode 100644 index 0000000..3654783 --- /dev/null +++ b/developer-simulation/runs/2026-08-03--offline-door-policy-update/README.md @@ -0,0 +1,53 @@ +# Offline door policy update + +This is a host-side fit probe for frequent offline door-controller policy +updates. Fold successfully applies grant retractions and policy-version metadata +in one logical transaction, but the stated controller requires a fixed 16 MiB +flash image, a 4 MiB working-memory bound, signed and truncation-safe delivery, +and old-or-new recovery after every modeled 4 KiB write. + +The reviewed conclusion is no fit for the controller. Fold uses a +filesystem-backed embedded store and does not expose the required raw-block fault +model. No BogKit correctness defect was found. + +## Reproduce + +Run from the repository root. The binary requires an empty caller-supplied state +directory, so tests and demonstrations never generate databases inside the +archive. + +```sh +export CARGO_TARGET_DIR=/private/tmp/offline-door-policy-target +cargo fmt --manifest-path developer-simulation/Cargo.toml \ + -p offline-door-policy-fit-probe -- --check +cargo test --manifest-path developer-simulation/Cargo.toml \ + -p offline-door-policy-fit-probe --offline --locked +cargo clippy --manifest-path developer-simulation/Cargo.toml \ + -p offline-door-policy-fit-probe --all-targets --offline --locked -- -D warnings +cargo build --manifest-path developer-simulation/Cargo.toml \ + -p offline-door-policy-fit-probe --release --offline --locked + +DOOR_ROOT="$(mktemp -d /private/tmp/offline-door-policy.XXXXXX)" +"$CARGO_TARGET_DIR/release/offline-door-policy-fit-probe" "$DOOR_ROOT" +``` + +Expected evidence includes: + +- zero mismatches across 40,000 generated authorization queries; +- 50,000 revoked grants applied within the two-second host criterion; +- a wrong-base next version rejected as `BaseMismatch` without mutation; +- same-version delivery labeled `SameVersionIgnoredUnverified` because bundle + identity and authenticity are outside the probe; +- old and skipped versions rejected, contiguous repair accepted, and active + policy preserved across clean checkpoint/reopen; +- a regular 16 MiB file rejected as the database path; +- explicit `not_provided` or `not_injectable` results for signature, + truncation, and per-4 KiB power-cut requirements. + +Whole-process RSS includes the reference map, generated bundles, runtime, code, +and database mappings. It is not a Fold-only or controller-memory measurement. +The missing-version diagnostic is process-local; only active version and last +verified time are persisted. + +See `TRIAL_REPORT.md` for the full blind-developer trail, the reviewer-discovered +wrong-base defect and regression, decision audit, and exact limitations. diff --git a/developer-simulation/runs/2026-08-03--offline-door-policy-update/TRIAL_REPORT.md b/developer-simulation/runs/2026-08-03--offline-door-policy-update/TRIAL_REPORT.md new file mode 100644 index 0000000..4683966 --- /dev/null +++ b/developer-simulation/runs/2026-08-03--offline-door-policy-update/TRIAL_REPORT.md @@ -0,0 +1,273 @@ +# Blind trial report: offline door policy update + +## Result + +**Decision: explicit no-fit for BogKit on the door controller.** + +Fold is useful for one narrow part of the problem: it can apply grant retractions and a policy-version record in one logical transaction, and it did so quickly in the host probe. It does not expose the storage and recovery controls that define this controller problem: a fixed 16 MiB flash image, bounded working memory, signed/truncation-safe bundle intake, and failure injection after each modeled 4 KiB write. ESE and ANNy have no relationship to exact authorization policy lookup. + +The runnable probe is intentionally a fit test, not a claim that the acceptance contract has been implemented. It demonstrates the useful portion and makes the missing portions observable. + +## Perspective and scope + +I approached the repository as a building-access systems developer who is new to Rust and had no prior BogKit knowledge. I read the root `README.md` first, then all four public examples. I did not inspect earlier simulations, lab reports, another checkout, GitHub, automation state, or internet resources. + +Starting revision: `80fd3c9`. + +Finishing criteria were: + +1. Evaluate the existing nightly-file baseline before selecting a component. +2. Select only a BogKit component with a plausible connection to the problem. +3. Produce a runnable minimal prototype or failure reproducer under `trial-output`. +4. Check formatting, tests, strict lint, representative correctness, timing, memory, storage, restart, and version-order behavior. +5. Preserve every untested acceptance item as an explicit limitation. + +## Baseline evaluation + +The current complete-nightly-file design is not safe if it overwrites the active sorted table in place. The probe models an 8 KiB old generation, writes the first 4 KiB block of the new generation, and then stops. The result is neither the complete old generation nor the complete new generation: + +```text +baseline_mixed_after_first_4k_write=true +``` + +A controller-specific design could stage a complete image in a second slot and switch a redundant activation record only after verification. That would solve mixed generations for snapshots, but it would not solve frequent emergency deltas, contiguous-version enforcement, signatures, or bounded delta recovery by itself. The baseline therefore justifies a change, but it does not make a general incremental database automatically suitable. + +## Ordered discovery and friction trail + +1. The root README describes Fold as an incremental programming framework whose materialized views update as data changes. It describes ESE as embeddings and ANNy as approximate nearest-neighbor search. +2. The `starter` example showed the relevant idea: a batch of inserts/removals commits atomically and can be reopened from a directory path. +3. The `timeseries` example showed incremental retractions, but no fixed-storage or fault model. `chat` and `search` were unrelated to exact offline authorization. +4. Fold's public crate documentation says its state is an embedded Fjall LSM store. `Stream::new` opens a path as a database directory, transactions are logical and crash-safe, and `checkpoint` performs a filesystem synchronization for OS/power durability. +5. That justified a narrow probe: use `KeyedStream` for credential/door grants, store the active version in the same transaction, and keep version-contiguity checks in controller application code. +6. The first `cargo test` attempted to update the package index and failed because the host could not resolve `index.crates.io`. No network access was used. Repeating with `--offline` succeeded from the existing cache. +7. Opening a pre-created 16 MiB file as a Fold database panicked inside `Stream::new`; the probe catches this and reports `fixed_16mib_image_rejected=true`. Fold expects a filesystem directory, not the required flash image/block interface. +8. The logical prototype passed generated authorization comparisons, version-order cases, and restart. Its host performance was fast. +9. Skeptical review found that a contiguous next version with the wrong + `based_on` value was mislabeled as a version gap and rendered the impossible + range `missing=2..1`. The coordinator separated base mismatch from missing + versions, added a no-mutation/reopen regression, and reran all evidence. +10. In the final nested workspace, the open store had 14 files and a + 67,115,332-byte logical extent, of which 3,129,344 bytes were physically + allocated on this sparse-file-capable host. After a clean close it had 11 + files, 3,072,778 logical bytes, and 3,104,768 allocated bytes. The layout + relies on host filesystem behavior and is not a fixed 16 MiB image. +11. Three final runs reported 25,526,272-26,820,608 bytes whole-process peak + resident memory. This includes the reference model, generated bundles, + runtime, code, and database mappings, so the 4 MiB controller working-memory + requirement remains not demonstrated rather than attributed to Fold. + +## Component fit audit + +| Component | Possible value | Decision | +|---|---|---| +| Fold | Atomic grant retractions, persisted keyed lookup, consistent version update | Rejected for controller use: filesystem/LSM persistence is not the fixed flash protocol; signed/truncated intake, bounded-memory evidence, and 4 KiB fault controls remain outside the probe | +| ESE | Static text embeddings | No fit: authorization is exact structured lookup, not semantic similarity | +| ANNy | Approximate vector search | No fit: approximate results are unacceptable and unrelated to badge/door/time checks | + +Using Fold only in the central compiler would not address the problem either: PostgreSQL remains authoritative, and the requested prototype value lies in controller image/recovery behavior. Adding Fold there would duplicate state without proving a required property. + +## Prototype + +Files: + +- `Cargo.toml`: nested-workspace member with a local Fold dependency. +- `src/main.rs`: baseline mixed-generation reproducer, versioned grant + controller, reference comparer, regular-file-path rejection probe, timing, + storage measurement, and restart check. +- `README.md`: exact reproduction using caller-supplied state under + `/private/tmp`; tests and demos do not generate state inside the archive. + +The probe uses one simulated controller for door 7. Version 1 contains 60,000 +time-bounded grants. Version 2 revokes 50,000. It compares 20,000 deterministic +badge/door/time queries before and 20,000 after against a straightforward +`BTreeMap` reference. It then rejects a next-version wrong-base bundle without +mutation, ignores the same active version without verifying payload identity, +rejects an older snapshot, rejects version 4 before version 3, and applies the +version 3 repair followed by version 4. Version and last-verified time are +persisted with the grants in the same Fold transaction. A rejected gap's +diagnostic range remains process-local and is not claimed to survive restart. + +This is deliberately not a signed-bundle parser, flash emulator, or exhaustive power-loss harness. Those are the capabilities whose absence drives the no-fit decision. + +## Representative acceptance evidence + +| Acceptance item | Evidence | Result | +|---|---|---| +| Queries match reference | 20,000 before + 20,000 after; zero mismatches | Pass in host probe | +| Old bundle cannot restore activated revocations | Version 1 after active version 2 returned `RejectedOld`; badge 0 stayed denied | Pass in application layer | +| Same active version ignored | Repeated version 2 returned `SameVersionIgnoredUnverified`; payload identity/authenticity is absent | Narrow pass in application layer | +| Wrong base rejected | Version 2 based on version 0 returned `BaseMismatch`; policy/status stayed at version 1 and the grant survived reopen | Pass after reviewer-required fix | +| Gap rejected and contiguous repair accepted | Version 4 returned missing 3; version 3 then version 4 activated | Pass in application layer | +| Deterministic status | Gap status was deterministic in-process; final/reopened active status was version 4, time 1030, no missing versions | Narrow pass; rejected-gap diagnostic is volatile | +| Restart preserves active policy | Reopen reported version 4 and authorized the repaired temporary grant at its valid time | Pass after clean checkpoint | +| 50,000 changes under 2 seconds | Three final runs applied in 48-51 ms and checkpointed in 4-5 ms | Pass for unsigned host transaction only | +| Signed and truncated bundles | The probe has no envelope/parser/signature implementation | Not implemented; application-specific boundary | +| Complete old or new at every 4 KiB power cut | Baseline mixing reproduced; Fold API does not expose each physical 4 KiB write or an injectable block device | Not demonstrated / missing capability | +| Peak working memory below 4 MiB | Whole probe RSS 25,526,272-26,820,608 bytes; not isolated to controller state or Fold | Not demonstrated | +| Active + recovery within 16 MiB fixed image | Fixed file rejected. Open directory: 67,115,332 logical / 3,129,344 allocated bytes. Closed: 3,072,778 logical / 3,104,768 allocated bytes | Required storage model not supported | + +## Exact commands and observed results + +Final commands ran from the repository root after this crate joined the nested +`developer-simulation` workspace and resolved its shared lock. Generated state +and build output stayed under `/private/tmp`. + +```console +cargo fmt --manifest-path developer-simulation/Cargo.toml \ + -p offline-door-policy-fit-probe -- --check +CARGO_TARGET_DIR=/private/tmp/bogkit-sim-final-target \ + cargo test --manifest-path developer-simulation/Cargo.toml \ + -p offline-door-policy-fit-probe --offline --locked +CARGO_TARGET_DIR=/private/tmp/bogkit-sim-final-target \ + cargo clippy --manifest-path developer-simulation/Cargo.toml \ + -p offline-door-policy-fit-probe --all-targets --offline --locked -- -D warnings +CARGO_TARGET_DIR=/private/tmp/bogkit-sim-final-target \ + cargo build --manifest-path developer-simulation/Cargo.toml \ + -p offline-door-policy-fit-probe --release --offline --locked + +DOOR_BIN=/private/tmp/bogkit-sim-final-target/release/offline-door-policy-fit-probe +for RUN_NUMBER in 1 2 3; do + DOOR_ROOT="$(mktemp -d /private/tmp/offline-door-final-${RUN_NUMBER}.XXXXXX)" + "$DOOR_BIN" "$DOOR_ROOT" +done +``` + +Formatting and strict Clippy passed. Both tests passed, including the +reviewer-required wrong-base/no-mutation/reopen regression. Each of the three +release demonstrations printed the same decisions and storage figures. One +representative decision/status sequence was: + +```text +baseline_mixed_after_first_4k_write=true +fixed_16mib_image_rejected=true +queries_before=20000 mismatches=0 +queries_after=20000 mismatches=0 +wrong_base=BaseMismatch { expected_base: 1, received_base: 0 } wrong_base_status=active_version=1 last_verified_at=1000 missing=none wrong_base_unchanged=true +same_version=SameVersionIgnoredUnverified old=RejectedOld gap=Missing { expected: 3, received: 4 } +gap_status=active_version=2 last_verified_at=1010 missing=3..3 +repair=Activated after_repair=Activated +final_status=active_version=4 last_verified_at=1030 missing=none +reopened_status=active_version=4 last_verified_at=1030 missing=none reopened_query=true +fold_store_open_files=14 open_logical_bytes=67115332 open_allocated_bytes=3129344 +fold_store_closed_files=11 closed_logical_bytes=3072778 closed_allocated_bytes=3104768 policy_limit_bytes=16777216 +signature_verification=not_provided_by_fold +truncated_bundle_detection=not_provided_by_fold +power_cut_at_each_4k_write=not_injectable_through_fold_api +``` + +Across the three final nested-workspace runs, the 60,000-entry snapshot applied +in 49-63 ms, 50,000 revocations applied in 48-51 ms, checkpoint took 4-5 ms, +and whole-probe RSS was 25,526,272-26,820,608 bytes. These are host observations +for this full harness, not controller-hardware or Fold-only bounds. + +## Categorized findings + +### F-0 — Wrong-base bundle was mislabeled, fixed after review + +- Category: **prototype correctness defect** +- Severity: **high** +- Confidence: **high** +- Reproduction: after active version 1, submit version 2 with `based_on=0`. + The original probe returned `Missing { expected: 2, received: 2 }` and + rendered `missing=2..1`. +- Fix and regression: the corrected application layer returns `BaseMismatch`, + keeps the policy and coherent status unchanged, and proves the grant survives + reopen in `contiguous_version_with_wrong_base_is_rejected_without_mutation`. +- Smallest improvement: keep base mismatch distinct from a missing version and + fail closed without applying changes. + +This was a defect in the trial prototype, not in Fold. + +### F-1 — Fixed-image storage mismatch + +- Category: **poor product fit** +- Severity: **critical** +- Confidence: **high** +- Reproduction: run the release demonstration; it pre-creates a 16 MiB file and attempts `Stream::new` on it. Output is `fixed_16mib_image_rejected=true`. +- Impact: the required simulator must control every byte and every 4 KiB flash write. Fold opens a directory-backed Fjall database instead. +- Smallest improvement: document the fixed/raw-flash non-goal and use a + purpose-built fixed-extent controller format; do not infer a new BogKit + storage engine from this trial. + +### F-2 — No controllable 4 KiB recovery boundary + +- Category: **missing capability** +- Severity: **critical** +- Confidence: **high** +- Reproduction: Fold exposes a logical transaction and `checkpoint`, but no write-plan enumeration, raw block backend, dual-root activation record, or power-cut injection point. The demo can only report `power_cut_at_each_4k_write=not_injectable_through_fold_api`. +- Impact: “crash-safe” at the host filesystem layer cannot establish the required claim that every modeled flash cut yields exactly the old or new policy. +- Smallest improvement: build the controller-specific recovery harness around a + purpose-built block format and retain this boundary in the public capability + matrix. + +### F-3 — No trusted bundle boundary + +- Category: **missing capability** +- Severity: **high** +- Confidence: **high** +- Reproduction: the public component API accepts typed inserts/removals; it has no bundle framing, length validation, signature verification, monotonic sequence envelope, or snapshot/delta distinction. +- Impact: untrusted, duplicated, skipped, reordered, or truncated network input must be solved entirely outside BogKit before a transaction begins. +- Smallest improvement: implement a streaming, signed application envelope that + verifies header, payload length/hash/signature, controller identity, base + version, and target version before yielding changes. This one-trial need does + not justify a BogKit subsystem candidate. + +### F-4 — Working-memory limit not demonstrated + +- Category: **performance problem** +- Severity: **high** +- Confidence: **medium** +- Reproduction: three final release demonstrations reported whole-process peak + RSS of 25,526,272-26,820,608 bytes against a 4,194,304-byte target. +- Impact: the controller memory contract is not demonstrated. +- Caveat: RSS includes the reference `BTreeMap`, both generated bundles, Rust runtime, and database; it does not isolate Fold's peak. The conclusion is limited to this implementation, not a precise Fold-only byte count. +- Smallest improvement: provide a streaming transaction interface with a documented fixed memory bound plus allocator/storage telemetry that separates engine memory from the harness. + +### F-5 — Regular-file database path causes a documented open panic + +- Category: **API friction** +- Severity: **high** +- Confidence: **high** +- Reproduction: opening the pre-created regular 16 MiB file as the database path + panics; the public constructor documents that store-open failure can panic. + The probe does not test general corruption or every unopenable-store case. +- Impact: an embedded policy controller needs a recoverable, diagnosable status + for an incompatible storage region, not process termination. +- Smallest improvement: return a typed `Result` from database construction and checkpoint operations. + +### F-6 — Durability boundary is easy to overread + +- Category: **documentation gap** +- Severity: **medium** +- Confidence: **high** +- Reproduction: the root-level messaging emphasizes durable, atomic transactions. The more precise Fold API documentation says `wtx` is process-crash durable while `checkpoint` additionally hardens against OS/power failure. Neither defines guarantees for torn physical 4 KiB flash writes or a capacity bound. +- Impact: a new user could mistake logical transaction atomicity for the controller's physical power-loss acceptance criterion. +- Smallest improvement: document the exact durability layers, unsupported raw-flash cases, expected directory/file behavior, storage amplification, and whether embedded/no-std targets are supported. + +No Fold correctness defect was demonstrated. The successful logical results should not be relabeled as an engine bug merely because the product boundary does not fit. + +## Decision audit + +1. **Why not keep the baseline?** Direct in-place full-file replacement demonstrably mixes generations after one 4 KiB write. Dual slots would make snapshots safer but do not provide efficient frequent deltas or the complete delivery protocol. +2. **Why consider Fold?** Grant add/revoke operations and version metadata form a transactional keyed-state update, matching Fold's advertised strength. +3. **What did Fold prove?** Exact lookup matched the reference; retractions and + version metadata committed together; corrected wrong-base/old/gap rules + implemented above Fold behaved correctly; the same active version was ignored + without verifying payload identity; 50,000 retractions were fast on the host. +4. **Why reject it?** The dominant requirements are below Fold's public abstraction: fixed flash extent, bounded memory, signed/truncated input, explicit two-generation activation, and exhaustive 4 KiB power-fault recovery. +5. **Could surrounding code fill the gaps?** Yes, but that surrounding code would contain nearly the entire safety-critical controller design. Fold would then add a host filesystem LSM that the fixed-image prototype cannot use, so the integration cost has no demonstrated payoff. +6. **Final choice:** use no BogKit component in the controller prototype. Build a purpose-specific fixed-image format with redundant generation metadata, a staged inactive region, streaming verification, monotonic contiguous version checks, and an activation record designed for torn-write recovery. Keep PostgreSQL as the authoritative compiler input as required. + +## Uncertainties and intentionally unproven items + +- The brief does not give a maximum per-controller grant count. The probe used 60,000 grants so one controller could receive the complete 50,000-revocation batch; this may be more concentrated than production. +- Host timing does not include real signature verification or bundle parsing and is not a hardware performance prediction. +- Whole-process RSS is an upper bound for this host prototype, not an isolated engine-memory profile. +- The open store's large logical extent is sparse on this filesystem. Physical allocation was only about 3.1 MiB, but sparse host files do not establish compatibility with a fixed raw-flash image. +- Only clean checkpoint/reopen was tested. No claim is made about the required old-or-new result at each physical write cut. +- Missing-version status in this minimal probe is deterministic within the + current process but reopens as `missing=none`; a production controller would + need a specified persisted diagnostic policy. +- Same-version delivery is ignored without proving that its payload matches the + active bundle. Bundle identity and authenticity remain outside the probe. +- Signature choice, key provisioning, bundle wire format, wear limits, flash erase geometry, and hardware-specific atomic-write size remain outside this no-fit reproducer. diff --git a/developer-simulation/runs/2026-08-03--offline-door-policy-update/src/main.rs b/developer-simulation/runs/2026-08-03--offline-door-policy-update/src/main.rs new file mode 100644 index 0000000..3a115a1 --- /dev/null +++ b/developer-simulation/runs/2026-08-03--offline-door-policy-update/src/main.rs @@ -0,0 +1,612 @@ +use std::collections::BTreeMap; +use std::fs; +use std::mem::MaybeUninit; +use std::os::unix::fs::MetadataExt; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use fold::pipeline::terminal; +use fold::stream::{KeyedStream, Stream}; +use serde::{Deserialize, Serialize}; + +const POLICY_BYTES: u64 = 16 * 1024 * 1024; +const CONTROLLER_DOOR: u16 = 7; +const INITIAL_GRANTS: u32 = 60_000; +const EMERGENCY_REVOCATIONS: u32 = 50_000; +const QUERY_CHECKS: usize = 20_000; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +enum PolicyKey { + Meta, + Grant { badge: u32, door: u16 }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +enum PolicyRecord { + Meta(PersistedStatus), + Grant(Grant), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct PersistedStatus { + active_version: u64, + last_verified_at: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct Grant { + not_before: u64, + not_after: u64, +} + +#[derive(Clone, Debug)] +enum Change { + Grant { badge: u32, door: u16, grant: Grant }, + Revoke { badge: u32, door: u16 }, +} + +#[derive(Clone, Debug)] +struct Bundle { + based_on: u64, + version: u64, + verified_at: u64, + changes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum ApplyResult { + Activated, + SameVersionIgnoredUnverified, + RejectedOld, + BaseMismatch { + expected_base: u64, + received_base: u64, + }, + Missing { + expected: u64, + received: u64, + }, +} + +struct Controller { + state: KeyedStream, + active_version: u64, + last_verified_at: u64, + missing: Option<(u64, u64)>, +} + +impl Controller { + fn open(path: &Path) -> Self { + let state = KeyedStream::new(path, terminal::Count::new("record_count")); + let persisted = match state.get(&PolicyKey::Meta) { + Some(PolicyRecord::Meta(status)) => status, + Some(PolicyRecord::Grant(_)) | None => PersistedStatus { + active_version: 0, + last_verified_at: 0, + }, + }; + Self { + state, + active_version: persisted.active_version, + last_verified_at: persisted.last_verified_at, + missing: None, + } + } + + fn apply(&mut self, bundle: &Bundle) -> ApplyResult { + if bundle.version == self.active_version { + return ApplyResult::SameVersionIgnoredUnverified; + } + if bundle.version < self.active_version { + return ApplyResult::RejectedOld; + } + + let expected = self.active_version + 1; + if bundle.version != expected { + self.missing = Some((expected, bundle.version)); + return ApplyResult::Missing { + expected, + received: bundle.version, + }; + } + if bundle.based_on != self.active_version { + self.missing = None; + return ApplyResult::BaseMismatch { + expected_base: self.active_version, + received_base: bundle.based_on, + }; + } + + self.state.wtx(|tx| { + for change in &bundle.changes { + match change { + Change::Grant { badge, door, grant } => { + tx.upsert( + &PolicyKey::Grant { + badge: *badge, + door: *door, + }, + &PolicyRecord::Grant(grant.clone()), + ); + } + Change::Revoke { badge, door } => { + tx.remove(&PolicyKey::Grant { + badge: *badge, + door: *door, + }); + } + } + } + tx.upsert( + &PolicyKey::Meta, + &PolicyRecord::Meta(PersistedStatus { + active_version: bundle.version, + last_verified_at: bundle.verified_at, + }), + ); + }); + + self.active_version = bundle.version; + self.last_verified_at = bundle.verified_at; + self.missing = None; + ApplyResult::Activated + } + + fn authorize(&self, badge: u32, door: u16, at: u64) -> bool { + match self.state.get(&PolicyKey::Grant { badge, door }) { + Some(PolicyRecord::Grant(grant)) => grant.not_before <= at && at < grant.not_after, + Some(PolicyRecord::Meta(_)) | None => false, + } + } + + fn checkpoint(&mut self) { + self.state.checkpoint(); + } + + fn status(&self) -> String { + match self.missing { + Some((expected, received)) => format!( + "active_version={} last_verified_at={} missing={}..{}", + self.active_version, + self.last_verified_at, + expected, + received.saturating_sub(1) + ), + None => format!( + "active_version={} last_verified_at={} missing=none", + self.active_version, self.last_verified_at + ), + } + } +} + +fn grant_key(badge: u32, door: u16) -> PolicyKey { + PolicyKey::Grant { badge, door } +} + +fn initial_snapshot() -> Bundle { + let changes = (0..INITIAL_GRANTS) + .map(|badge| Change::Grant { + badge, + door: CONTROLLER_DOOR, + grant: Grant { + not_before: 100, + not_after: 10_000, + }, + }) + .collect(); + Bundle { + based_on: 0, + version: 1, + verified_at: 1_000, + changes, + } +} + +fn revocation_bundle() -> Bundle { + let changes = (0..EMERGENCY_REVOCATIONS) + .map(|badge| Change::Revoke { + badge, + door: CONTROLLER_DOOR, + }) + .collect(); + Bundle { + based_on: 1, + version: 2, + verified_at: 1_010, + changes, + } +} + +fn reference_authorize( + reference: &BTreeMap, + badge: u32, + door: u16, + at: u64, +) -> bool { + reference + .get(&grant_key(badge, door)) + .is_some_and(|grant| grant.not_before <= at && at < grant.not_after) +} + +fn check_queries(controller: &Controller, reference: &BTreeMap) -> usize { + let mut mismatches = 0; + let mut state = 0x9e37_79b9_u64; + for _ in 0..QUERY_CHECKS { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1); + let badge = ((state >> 16) % 70_000) as u32; + let door = if state & 1 == 0 { + CONTROLLER_DOOR + } else { + CONTROLLER_DOOR + 1 + }; + let at = (state >> 32) % 12_000; + if controller.authorize(badge, door, at) != reference_authorize(reference, badge, door, at) + { + mismatches += 1; + } + } + mismatches +} + +fn store_stats(path: &Path) -> std::io::Result<(u64, u64, usize)> { + let mut logical_bytes = 0; + let mut allocated_bytes = 0; + let mut files = 0; + let mut pending = vec![path.to_path_buf()]; + while let Some(item) = pending.pop() { + let metadata = fs::metadata(&item)?; + if metadata.is_dir() { + for entry in fs::read_dir(item)? { + pending.push(entry?.path()); + } + } else if metadata.is_file() { + logical_bytes += metadata.len(); + allocated_bytes += metadata.blocks() * 512; + files += 1; + } + } + Ok((logical_bytes, allocated_bytes, files)) +} + +fn fixed_image_probe(path: &Path) -> std::io::Result { + let image = path.join("fixed-flash.img"); + let file = fs::File::create(&image)?; + file.set_len(POLICY_BYTES)?; + drop(file); + + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let open_result = catch_unwind(AssertUnwindSafe(|| { + let _stream: Stream = + Stream::new(&image, terminal::Count::new("probe")); + })); + std::panic::set_hook(previous_hook); + Ok(open_result.is_err()) +} + +fn naive_baseline_mixes_generations_after_one_block() -> bool { + let old_generation = [0x11_u8; 8 * 1024]; + let new_generation = [0x22_u8; 8 * 1024]; + let mut flash = old_generation; + flash[..4 * 1024].copy_from_slice(&new_generation[..4 * 1024]); + flash != old_generation && flash != new_generation +} + +fn run_root() -> Result { + let mut args = std::env::args_os(); + let program = args + .next() + .unwrap_or_else(|| "offline-door-policy-fit-probe".into()); + let Some(root) = args.next() else { + return Err(format!( + "usage: {} ", + Path::new(&program).display() + )); + }; + if args.next().is_some() { + return Err(format!( + "usage: {} ", + Path::new(&program).display() + )); + } + Ok(PathBuf::from(root)) +} + +fn peak_rss_bytes() -> Option { + let mut usage = MaybeUninit::::uninit(); + // SAFETY: getrusage initializes the provided rusage when it returns zero. + let result = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; + if result != 0 { + return None; + } + // SAFETY: a successful getrusage call initialized usage above. + let usage = unsafe { usage.assume_init() }; + #[cfg(target_os = "macos")] + { + Some(usage.ru_maxrss as u64) + } + #[cfg(not(target_os = "macos"))] + { + Some((usage.ru_maxrss as u64) * 1024) + } +} + +fn main() -> Result<(), Box> { + let root = run_root()?; + if root.exists() && fs::read_dir(&root)?.next().is_some() { + return Err(format!("state directory must be empty: {}", root.display()).into()); + } + fs::create_dir_all(&root)?; + + let baseline_mixed = naive_baseline_mixes_generations_after_one_block(); + let fixed_image_rejected = fixed_image_probe(&root)?; + let store_path = root.join("fold-store"); + let mut controller = Controller::open(&store_path); + let mut reference = BTreeMap::new(); + + let snapshot = initial_snapshot(); + let snapshot_started = Instant::now(); + assert_eq!(controller.apply(&snapshot), ApplyResult::Activated); + let snapshot_apply_ms = snapshot_started.elapsed().as_millis(); + for change in &snapshot.changes { + if let Change::Grant { badge, door, grant } = change { + reference.insert(grant_key(*badge, *door), grant.clone()); + } + } + let before_mismatches = check_queries(&controller, &reference); + controller.checkpoint(); + + let wrong_base_bundle = Bundle { + based_on: 0, + version: 2, + verified_at: 1_005, + changes: vec![Change::Revoke { + badge: 55_000, + door: CONTROLLER_DOOR, + }], + }; + let wrong_base = controller.apply(&wrong_base_bundle); + let wrong_base_status = controller.status(); + let wrong_base_unchanged = controller.authorize(55_000, CONTROLLER_DOOR, 1_500); + + let revocations = revocation_bundle(); + let delta_started = Instant::now(); + assert_eq!(controller.apply(&revocations), ApplyResult::Activated); + let delta_apply_ms = delta_started.elapsed().as_millis(); + let checkpoint_started = Instant::now(); + controller.checkpoint(); + let checkpoint_ms = checkpoint_started.elapsed().as_millis(); + for badge in 0..EMERGENCY_REVOCATIONS { + reference.remove(&grant_key(badge, CONTROLLER_DOOR)); + } + let after_mismatches = check_queries(&controller, &reference); + + let same_version = controller.apply(&revocations); + let old = controller.apply(&snapshot); + let version_four = Bundle { + based_on: 3, + version: 4, + verified_at: 1_030, + changes: vec![Change::Grant { + badge: 99_999, + door: CONTROLLER_DOOR, + grant: Grant { + not_before: 1_000, + not_after: 2_000, + }, + }], + }; + let gap = controller.apply(&version_four); + let gap_status = controller.status(); + let version_three = Bundle { + based_on: 2, + version: 3, + verified_at: 1_020, + changes: Vec::new(), + }; + let repair = controller.apply(&version_three); + let after_repair = controller.apply(&version_four); + let final_status = controller.status(); + controller.checkpoint(); + + let (open_logical_bytes, open_allocated_bytes, open_store_files) = store_stats(&store_path)?; + let revoked_deny = !controller.authorize(0, CONTROLLER_DOOR, 1_500); + let final_allow = controller.authorize(99_999, CONTROLLER_DOOR, 1_500); + let expired_deny = !controller.authorize(99_999, CONTROLLER_DOOR, 2_000); + drop(controller); + let reopened = Controller::open(&store_path); + let reopened_status = reopened.status(); + let reopened_allow = reopened.authorize(99_999, CONTROLLER_DOOR, 1_500); + drop(reopened); + let (closed_logical_bytes, closed_allocated_bytes, closed_store_files) = + store_stats(&store_path)?; + println!("baseline_mixed_after_first_4k_write={baseline_mixed}"); + println!("fixed_16mib_image_rejected={fixed_image_rejected}"); + println!( + "snapshot_entries={} snapshot_apply_ms={snapshot_apply_ms}", + snapshot.changes.len() + ); + println!("queries_before={QUERY_CHECKS} mismatches={before_mismatches}"); + println!( + "revocations={} delta_apply_ms={delta_apply_ms} checkpoint_ms={checkpoint_ms}", + revocations.changes.len() + ); + println!("queries_after={QUERY_CHECKS} mismatches={after_mismatches}"); + println!( + "wrong_base={wrong_base:?} wrong_base_status={wrong_base_status} wrong_base_unchanged={wrong_base_unchanged}" + ); + println!("same_version={same_version:?} old={old:?} gap={gap:?}"); + println!("gap_status={gap_status}"); + println!("repair={repair:?} after_repair={after_repair:?}"); + println!("final_status={final_status}"); + println!("reopened_status={reopened_status} reopened_query={reopened_allow}"); + println!( + "fold_store_open_files={open_store_files} open_logical_bytes={open_logical_bytes} open_allocated_bytes={open_allocated_bytes}" + ); + println!( + "fold_store_closed_files={closed_store_files} closed_logical_bytes={closed_logical_bytes} closed_allocated_bytes={closed_allocated_bytes} policy_limit_bytes={POLICY_BYTES}" + ); + let peak_rss = peak_rss_bytes().ok_or("peak RSS measurement unavailable")?; + println!( + "whole_probe_peak_rss_bytes={peak_rss} working_memory_limit_bytes={}", + 4 * 1024 * 1024 + ); + println!("signature_verification=not_provided_by_fold"); + println!("truncated_bundle_detection=not_provided_by_fold"); + println!("power_cut_at_each_4k_write=not_injectable_through_fold_api"); + + assert!(baseline_mixed); + assert!(fixed_image_rejected); + assert_eq!(before_mismatches, 0); + assert_eq!(after_mismatches, 0); + assert_eq!( + wrong_base, + ApplyResult::BaseMismatch { + expected_base: 1, + received_base: 0, + } + ); + assert_eq!( + wrong_base_status, + "active_version=1 last_verified_at=1000 missing=none" + ); + assert!(wrong_base_unchanged); + assert_eq!(same_version, ApplyResult::SameVersionIgnoredUnverified); + assert_eq!(old, ApplyResult::RejectedOld); + assert_eq!( + gap, + ApplyResult::Missing { + expected: 3, + received: 4, + } + ); + assert_eq!(repair, ApplyResult::Activated); + assert_eq!(after_repair, ApplyResult::Activated); + assert!(revoked_deny); + assert!(final_allow); + assert!(expired_deny); + assert_eq!(reopened_status, final_status); + assert!(reopened_allow); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_store(name: &str) -> PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("system time must follow the Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "offline-door-policy-{name}-{}-{nonce}", + std::process::id() + )) + } + + #[test] + fn sequence_rules_and_time_bounds_are_deterministic() { + let path = test_store("sequence"); + if path.exists() { + fs::remove_dir_all(&path).unwrap(); + } + let mut controller = Controller::open(&path); + let v1 = Bundle { + based_on: 0, + version: 1, + verified_at: 10, + changes: vec![Change::Grant { + badge: 42, + door: CONTROLLER_DOOR, + grant: Grant { + not_before: 100, + not_after: 200, + }, + }], + }; + assert_eq!(controller.apply(&v1), ApplyResult::Activated); + assert!(!controller.authorize(42, CONTROLLER_DOOR, 99)); + assert!(controller.authorize(42, CONTROLLER_DOOR, 100)); + assert!(!controller.authorize(42, CONTROLLER_DOOR, 200)); + assert_eq!( + controller.apply(&v1), + ApplyResult::SameVersionIgnoredUnverified + ); + + let v3 = Bundle { + based_on: 2, + version: 3, + verified_at: 30, + changes: Vec::new(), + }; + assert_eq!( + controller.apply(&v3), + ApplyResult::Missing { + expected: 2, + received: 3, + } + ); + assert_eq!( + controller.status(), + "active_version=1 last_verified_at=10 missing=2..2" + ); + drop(controller); + fs::remove_dir_all(&path).unwrap(); + } + + #[test] + fn contiguous_version_with_wrong_base_is_rejected_without_mutation() { + let path = test_store("wrong-base"); + let mut controller = Controller::open(&path); + let v1 = Bundle { + based_on: 0, + version: 1, + verified_at: 10, + changes: vec![Change::Grant { + badge: 42, + door: CONTROLLER_DOOR, + grant: Grant { + not_before: 100, + not_after: 200, + }, + }], + }; + assert_eq!(controller.apply(&v1), ApplyResult::Activated); + + let wrong_base = Bundle { + based_on: 0, + version: 2, + verified_at: 20, + changes: vec![Change::Revoke { + badge: 42, + door: CONTROLLER_DOOR, + }], + }; + assert_eq!( + controller.apply(&wrong_base), + ApplyResult::BaseMismatch { + expected_base: 1, + received_base: 0, + } + ); + assert!(controller.authorize(42, CONTROLLER_DOOR, 150)); + assert_eq!( + controller.status(), + "active_version=1 last_verified_at=10 missing=none" + ); + drop(controller); + let reopened = Controller::open(&path); + assert!(reopened.authorize(42, CONTROLLER_DOOR, 150)); + assert_eq!( + reopened.status(), + "active_version=1 last_verified_at=10 missing=none" + ); + drop(reopened); + fs::remove_dir_all(&path).unwrap(); + } +} diff --git a/developer-simulation/runs/2026-08-03--provenance-revocation-impact/Cargo.toml b/developer-simulation/runs/2026-08-03--provenance-revocation-impact/Cargo.toml new file mode 100644 index 0000000..2bca1f5 --- /dev/null +++ b/developer-simulation/runs/2026-08-03--provenance-revocation-impact/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "provenance-revocation-reproducer" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +fold = { path = "../../../fold" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "deny" diff --git a/developer-simulation/runs/2026-08-03--provenance-revocation-impact/README.md b/developer-simulation/runs/2026-08-03--provenance-revocation-impact/README.md new file mode 100644 index 0000000..3a2f5bc --- /dev/null +++ b/developer-simulation/runs/2026-08-03--provenance-revocation-impact/README.md @@ -0,0 +1,65 @@ +# Provenance revocation impact + +This is a minimal fit reproducer for transitive software-provenance revocation. +It persists input facts with Fold, then compares an intentionally incomplete +one-hop negative control with a bounded recursive correctness oracle. The +negative control is ordinary Rust, not a composition of Fold operators; its two +unsafe approvals are prototype defects, not BogKit defects. + +The reviewed conclusion is no fit for the stated PostgreSQL-authoritative gate. +The current public Fold surface does not supply joins or recursive reachability, +and moving derived decisions into a local embedded store would add another +authority and reconciliation boundary. + +## Reproduce + +Run from the repository root. All generated state and build output remain under +`/private/tmp`. + +```sh +export CARGO_TARGET_DIR=/private/tmp/provenance-revocation-target +cargo fmt --manifest-path developer-simulation/Cargo.toml \ + -p provenance-revocation-reproducer -- --check +cargo test --manifest-path developer-simulation/Cargo.toml \ + -p provenance-revocation-reproducer --offline --locked +cargo clippy --manifest-path developer-simulation/Cargo.toml \ + -p provenance-revocation-reproducer --all-targets --offline --locked -- -D warnings +cargo build --manifest-path developer-simulation/Cargo.toml \ + -p provenance-revocation-reproducer --release --offline --locked + +PROV_ROOT="$(mktemp -d /private/tmp/provenance-revocation.XXXXXX)" +PROV_BIN="$CARGO_TARGET_DIR/release/provenance-revocation-reproducer" +"$PROV_BIN" generate | "$PROV_BIN" run "$PROV_ROOT/negative-control" candidate +"$PROV_BIN" generate | "$PROV_BIN" run "$PROV_ROOT/reference" reference +``` + +The negative control approves `transitive` and `cyclic`; the reference blocks +them as `revoked` and `invalid_cycle`. Both block `unknown` as +`missing_manifest`. This is a three-query failure reproducer, not a scale test. + +## Process-abort boundaries + +Build a separate abort-on-panic binary, then inject before and after the first +Fold transaction commits: + +```sh +export CARGO_TARGET_DIR=/private/tmp/provenance-revocation-abort-target +export RUSTFLAGS='-C panic=abort' +cargo build --manifest-path developer-simulation/Cargo.toml \ + -p provenance-revocation-reproducer --release --offline --locked + +CRASH_ROOT="$(mktemp -d /private/tmp/provenance-revocation-abort.XXXXXX)" +ABORT_BIN="$CARGO_TARGET_DIR/release/provenance-revocation-reproducer" +printf '%s\n' '{"op":"artifact","id":"app"}' \ + | PROVENANCE_CRASH=before_commit:1 "$ABORT_BIN" run "$CRASH_ROOT/before" reference +printf '%s\n' '{"op":"artifact","id":"app"}' \ + | PROVENANCE_CRASH=after_commit:1 "$ABORT_BIN" run "$CRASH_ROOT/after" reference +``` + +Each injected run exits by abort. Reopening the first store shows `app` absent; +reopening the second shows it present. This covers two local process-abort +boundaries only, not OS failure, power loss, PostgreSQL integration, versioned +publication, concurrent ingestion, or the requested 500,000-artifact scale. + +See `TRIAL_REPORT.md` for the full blind-developer trail, skeptical corrections, +decision audit, and exact limitations. diff --git a/developer-simulation/runs/2026-08-03--provenance-revocation-impact/TRIAL_REPORT.md b/developer-simulation/runs/2026-08-03--provenance-revocation-impact/TRIAL_REPORT.md new file mode 100644 index 0000000..27a8bfc --- /dev/null +++ b/developer-simulation/runs/2026-08-03--provenance-revocation-impact/TRIAL_REPORT.md @@ -0,0 +1,391 @@ +# Blind trial report: provenance revocation impact + +## Outcome + +**Explicit no-fit for BogKit's current public components.** Fold's atomic writes, +durable local state, retractions, and consistent read snapshots are useful +building blocks, but its documented operators do not provide a join, recursive +fixed point, or dynamic reachability primitive. Those are the center of this +problem, not an incidental feature. A correct solution would require a custom +graph engine plus deterministic witness and restart protocols, while also +creating a second durable authority beside PostgreSQL. + +The runnable reproducer deliberately stops at the smallest useful boundary. It +uses Fold to persist input facts, implements an intentionally incomplete one-hop +negative control in ordinary Rust, and compares it with a slow deterministic +graph reference. The negative control is not a composition of Fold operators and +its defects are not BogKit defects. On the demonstration corpus, it disagrees +with the reference on 2 of 3 release decisions: it approves both a transitively +revoked release and a release containing a reachable cycle. + +This is a product-fit conclusion, not a claim that arbitrary Rust code cannot be +written behind Fold's `Push` trait. It can. The problem is that doing so would be +implementing the missing product. + +## Scope and finishing criteria + +I worked only in this assigned checkout and wrote only under `trial-output/`. I +did not inspect prior simulations, reports, another checkout, GitHub, automation +state, or internet resources. I did not commit or push. + +I considered the trial complete when all of the following were true: + +1. The existing PostgreSQL baseline had been evaluated before selecting any + BogKit component. +2. The public README, examples, and relevant public Fold API were inspected. +3. A minimal JSONL reproducer persisted state locally and exposed the key fit + failure against a deterministic reference. +4. Duplicate edges, missing manifests, transitive revocation, cycles, fact-order + independence, and two transaction crash boundaries had evidence. +5. Formatting, compilation, strict linting, tests, and the demonstration passed. +6. Findings, limitations, commands, observed results, and the decision audit + were recorded without claiming unmeasured scale results. + +## Baseline evaluation before BogKit selection + +The PostgreSQL baseline already has two important properties: it is the named +authority, and recursive queries can calculate the exact reachable set from a +transactionally consistent database snapshot. Its problem is operational: +nightly full recomputation gives unacceptable revocation latency, while a +separate cached flag can represent a different generation than concurrently +uploaded manifests. + +The real PostgreSQL implementation was evaluated conceptually, not reproduced. +The runnable slow reference instead loads Fold facts into ordinary Rust +collections and serves only as a bounded correctness oracle. The smallest +direction that preserves the baseline's strengths is an +incremental reverse-dependency index and a versioned decision generation that +is published atomically in PostgreSQL. That could bound recomputation to nodes +reachable from changed roots, keep ingestion and decisions tied to database +versions, and publish explanations with the same generation. This trial did not +build that system because the requested question was whether BogKit components +fit the prototype boundary. + +Moving the derived decision set into Fold's embedded store would not, by itself, +fix the authority boundary. It would add a PostgreSQL-to-local-store checkpoint +and reconciliation problem. The prototype would need an explicit source offset, +idempotent replay, generation publication, and recovery protocol before its +decisions could safely gate deployment. + +## Public surface evaluated + +The root README presents Fold as an eager incremental programming framework and +points new users at four examples. I read all four public examples: + +- `starter`: atomic insert/retract into a persistent count and bag. +- `timeseries`: keying, filtering, and invertible per-key aggregation. +- `chat`: one thread owns the Fold stream and publishes snapshots after each + committed write. +- `search`: keyed upsert/retract fan-out into three independent indexes. + +These show a clear, useful model for one input delta flowing through a static +pipeline. The public Fold module documents these transformation operators: +`Map`, `Filter`, `FilterMap`, `FlatMap`, `Distinct`, `Aggregate`, `TopK`, and +`Retain`, plus keying/scoring and terminal views. Tuples broadcast a delta into +independent branches. There is no public join, recursion, feedback edge, +fixed-point iteration, or graph reachability operator. + +The public `Push` trait does permit a custom stateful operator with low-level +keyspaces and transaction hooks. For this workload, such an operator would need +to implement all of the following itself: + +- forward and reverse adjacency with duplicate-edge set semantics; +- incremental transitive invalidation and revalidation under deletions/updates; +- well-founded behavior for malformed cycles and incomplete manifests; +- a specified deterministic witness policy under every ingestion order; +- source offsets, idempotent replay, versioned publication, and crash recovery; +- storage compaction and memory controls at five million edges. + +That is the core application, so the custom-node escape hatch does not change +the no-fit decision. + +## What the reproducer contains + +- `Cargo.toml`: standalone Rust crate with a local path dependency on Fold. +- `src/main.rs`: one executable with two commands: + - `generate` writes a deterministic JSONL corpus. + - `run ` consumes JSONL, stores facts + in a Fold `Bag`, and emits JSONL decisions for query commands. +- `PROVENANCE_CRASH=before_commit:N` and + `PROVENANCE_CRASH=after_commit:N`: deterministic crash injection around the + Nth persistent fact. +- Five unit fixtures: complete DAG, transitive revocation, transitive missing + manifest, reachable cycle, and order/duplicate-edge determinism. + +Supported JSONL operations are `artifact`, `edge`, `release`, `revoke`, and +`query`. This intentionally does not claim full manifest update, attestation, +unrevocation, or multi-process ingestion support. It is a failure reproducer, +not a deployment-gate implementation. + +The `candidate` engine is an intentionally incomplete negative control that +checks the release artifact and one dependency hop. It is not an implemented +Fold pipeline and therefore cannot establish that a concrete Fold composition is +incorrect. The `reference` engine scans the persisted facts, +deduplicates edges with ordered sets, and performs deterministic depth-first +reachability. Unknown manifests and cycles block. Sorted traversal makes its +first witness path independent of fact ingestion order on the tested fixtures; +global minimality is not established. + +## Ordered discovery and friction trail + +1. Read the root README and enumerated `examples/`. This established the intended + onboarding path and the advertised component set. +2. Read `starter`, `timeseries`, `chat`, and `search`. Atomic snapshots and + retractions were immediately promising; no example correlated two changing + relations or fed results back to a fixed point. +3. Searched the public Fold API for join/recursion/iteration/feedback/cycle and + inspected the operator and stream documentation. The operator list confirmed + the missing primitive. The transaction API confirmed that all terminal + updates in one `wtx` are atomic and restart from the last commit. +4. Considered a custom `Push` node. It is technically possible, but it exposes + storage/transaction plumbing rather than a graph abstraction. Implementing it + would consume the whole prototype budget and still leave PostgreSQL + reconciliation outside the component. +5. Built the minimal candidate/reference executable under `trial-output/`. +6. The first validation command ran `cargo fmt --check`, which correctly showed + formatting differences. Because this new standalone crate had no lock file, + the subsequent non-offline Cargo commands attempted to update the crates.io + index and failed DNS resolution. No internet content was accessed. Running + `cargo fmt` and rerunning Cargo with `--offline` resolved dependencies from the + existing local cache and created the lock file. +7. The first demo invocation looked for a normal binary after only `cargo check` + and `cargo test`; only the test harness existed. `cargo build --offline + --locked` produced the executable, after which the demo passed. This was local + build-command friction, not a BogKit defect. +8. Ran the deterministic corpus through candidate and reference stores, then + injected crashes before and after a Fold commit and reopened each store. +9. Attempted `/usr/bin/time -l` for peak resident memory. Wall time was reported, + but the sandbox denied the required `sysctl kern.clockrate`, so peak memory was + not available. I do not claim a memory result. + +## Exact validation commands and observed results + +Final commands ran from the repository root after this crate joined the nested +`developer-simulation` workspace and resolved its shared locked dependencies. +Generated state and build output stayed under `/private/tmp`. + +### Formatting, compilation, lint, and tests + +```console +cargo fmt --manifest-path developer-simulation/Cargo.toml \ + -p provenance-revocation-reproducer -- --check +CARGO_TARGET_DIR=/private/tmp/bogkit-sim-final-target \ + cargo test --manifest-path developer-simulation/Cargo.toml \ + -p provenance-revocation-reproducer --offline --locked +CARGO_TARGET_DIR=/private/tmp/bogkit-sim-final-target \ + cargo clippy --manifest-path developer-simulation/Cargo.toml \ + -p provenance-revocation-reproducer --all-targets --offline --locked -- -D warnings +CARGO_TARGET_DIR=/private/tmp/bogkit-sim-final-target \ + cargo build --manifest-path developer-simulation/Cargo.toml \ + -p provenance-revocation-reproducer --release --offline --locked +``` + +Formatting, compilation, strict Clippy, and all 5 tests passed. + +### Deterministic demonstration + +```console +PROV_ROOT="$(mktemp -d /private/tmp/provenance-final.XXXXXX)" +PROV_BIN=/private/tmp/bogkit-sim-final-target/release/provenance-revocation-reproducer +"$PROV_BIN" generate | "$PROV_BIN" run "$PROV_ROOT/candidate" candidate +"$PROV_BIN" generate | "$PROV_BIN" run "$PROV_ROOT/reference" reference +du -sk "$PROV_ROOT/candidate" "$PROV_ROOT/reference" +``` + +Corpus counts: 14 persistent facts and 3 queries. The facts include 5 declared +artifacts, 5 edges (one exact duplicate), 3 releases, and 1 revocation. + +Candidate output: + +```jsonl +{"engine":"one_hop_negative_control","release":"transitive","decision":"approved","reason":"complete","path":[]} +{"engine":"one_hop_negative_control","release":"cyclic","decision":"approved","reason":"complete","path":[]} +{"engine":"one_hop_negative_control","release":"unknown","decision":"blocked","reason":"missing_manifest","path":["missing-root"]} +``` + +Reference output: + +```jsonl +{"engine":"slow_reference","release":"transitive","decision":"blocked","reason":"revoked","path":["app","middle","revoked-base"]} +{"engine":"slow_reference","release":"cyclic","decision":"blocked","reason":"invalid_cycle","path":["cycle-a","cycle-b","cycle-a"]} +{"engine":"slow_reference","release":"unknown","decision":"blocked","reason":"missing_manifest","path":["missing-root"]} +``` + +Observed decision match: 1 of 3. Observed mismatch: 2 of 3. Each tiny Fold state +directory occupied 48 KiB. That size is dominated by fixed storage overhead and +is not evidence for the 1.3x scale limit. + +### Crash boundaries + +The reviewer required process-abort, rather than only Rust-unwind, evidence. A +separate release binary was used for both boundaries: + +```console +RUSTFLAGS='-C panic=abort' \ + CARGO_TARGET_DIR=/private/tmp/bogkit-sim-abort-target \ + cargo build --manifest-path developer-simulation/Cargo.toml \ + -p provenance-revocation-reproducer --release --offline --locked +ABORT_BIN=/private/tmp/bogkit-sim-abort-target/release/provenance-revocation-reproducer +CRASH_ROOT="$(mktemp -d /private/tmp/provenance-abort.XXXXXX)" +``` + +Before-commit injection: + +```console +print -r -- '{"op":"artifact","id":"app"}' \ + | PROVENANCE_CRASH=before_commit:1 "$ABORT_BIN" run "$CRASH_ROOT/before" reference +``` + +Observed exit 134 from the injected abort. After reopening and adding only the +release, the query returned blocked/missing `app`, proving the interrupted fact +was rolled back: + +```json +{"engine":"slow_reference","release":"prod","decision":"blocked","reason":"missing_manifest","path":["app"]} +``` + +After-commit injection: + +```console +print -r -- '{"op":"artifact","id":"app"}' \ + | PROVENANCE_CRASH=after_commit:1 "$ABORT_BIN" run "$CRASH_ROOT/after" reference +``` + +Observed exit 134 from the injected abort. After reopening and adding the +release, the query returned approved, proving the committed artifact survived: + +```json +{"engine":"slow_reference","release":"prod","decision":"approved","reason":"complete","path":[]} +``` + +This validates Fold's local transaction boundary for the two tested process +aborts. It does not cover OS failure, power loss, or every phase of a versioned +derived-decision publication protocol, because the reproducer deliberately does +not invent that absent protocol. + +## Acceptance evidence and exact limitations + +| Acceptance item | Evidence | Result | +| --- | --- | --- | +| Exact impacted-release match | Three-query candidate/reference demo | **Fail: 2/3 mismatches** | +| 500k artifacts, 5m edges, 100 revocations under 60s | Not run after the correctness no-fit was established | **Unproven** | +| Admission below 250 ms during 100 updates/s | No concurrent ingestion harness; reference rescans all facts per query | **Unproven and unsuitable design** | +| Deterministic explanation path | Unit test reverses fact order and includes a duplicate edge | **Reference passes; candidate cannot explain transitive failures** | +| Unknown provenance never approved | Missing-root demo and transitive-missing unit fixture | **Reference passes tested cases** | +| Safe cycles | Reachable two-node cycle unit and demo | **Reference blocks; candidate incorrectly approves** | +| Crash restart equals uninterrupted publication | Before/after one Fold transaction tested | **Local atomicity passes; full publication protocol absent** | +| Peak memory below 512 MiB | Measurement unavailable; reference materializes the whole graph | **Unproven** | +| Auxiliary state below 1.3x input | Tiny store is 48 KiB, not scale-representative | **Unproven** | + +The slow reference is recursive and materializes ordered strings and adjacency +sets in memory on every query. It exists only as a correctness oracle on bounded +fixtures. It is not claimed to meet the performance or memory acceptance +criteria. A very deep malformed chain could also exhaust its call stack; that is +another reason it is not a production candidate. + +## Categorized findings + +### F-01 — One-hop negative control approves unsafe releases + +- Category: **prototype correctness defect** +- Severity: **blocker** +- Confidence: **high** +- Reproduction: run the generated corpus through both engines. `transitive` and + `cyclic` are approved by the negative control and blocked by the reference. +- Smallest improvement: do not use one-hop logic as the gate. Keep computation + in the authoritative database or choose a proven recursive graph system. + +This is a deliberately exposed defect in the negative-control prototype, not a +defect in Fold or evidence that an implemented Fold composition failed. + +### F-02 — No join or recursive fixed-point component + +- Category: **missing capability** +- Severity: **blocker** +- Confidence: **high** +- Reproduction: inspect the public operator list in `fold/src/pipeline/mod.rs` + and the public examples; search the public source for join, recursion, + feedback, and fixed-point APIs. +- Smallest improvement: document that joins and recursive reachability are not + supplied. A future durable recursive operator is only a one-trial observation, + not a threshold-qualified candidate. + +### F-03 — Embedded derived authority conflicts with PostgreSQL authority + +- Category: **poor product fit** +- Severity: **blocker** +- Confidence: **high** +- Reproduction: compare the stated constraint that PostgreSQL remains + authoritative with Fold's local embedded store and process-owned stream. +- Smallest improvement: offer a PostgreSQL-backed state/transaction adapter or + a documented exactly-once source-offset and generation-publication protocol. + Without that, keep computation and publication in PostgreSQL. + +### F-04 — Correct fallback repeats full-scan baseline behavior + +- Category: **performance problem** +- Severity: **blocker** +- Confidence: **high for asymptotic behavior; low for exact scale timing** +- Reproduction: every reference query iterates the entire persisted fact bag and + rebuilds artifact, edge, release, and revocation collections before traversal. +- Smallest improvement: maintain reverse reachability and a specified stable witness + incrementally by changed generation instead of scanning all facts per query. + +No 500k/5m benchmark was run, so this report makes no fabricated claim about +seconds or peak memory. + +### F-05 — Custom operator path exposes low-level implementation burden + +- Category: **API friction** +- Severity: **major** +- Confidence: **high** +- Reproduction: inspect `Push`, `PipelineInitCtx`, and `WriteTx`. A custom node + must manage named keyspaces, serialization, buffered deltas, repeated commit + calls, abort cleanup, and downstream readers. Public examples also use macros + when closure-containing pipeline types are difficult to name in helpers. +- Smallest improvement: ship supported join/recursive operators and ergonomic + typed builders/readers rather than requiring application authors to construct + storage engines at the `Push` layer. + +### F-06 — Recovery and evolution guidance is insufficient for a gate + +- Category: **documentation gap** +- Severity: **major** +- Confidence: **high** +- Reproduction: the public docs describe atomic `wtx`, snapshots, and + checkpointing, but the inspected public surface does not explain source offset + replay, schema/pipeline evolution, keyspace migration, crash points across + generations, or PostgreSQL reconciliation. +- Smallest improvement: document and test an end-to-end versioned materialization + protocol, including compatibility checks on reopen and crash matrices. + +## Decision audit + +1. **Keep nightly PostgreSQL recomputation unchanged:** rejected because it does + not meet revocation latency and allows cached-generation disagreement. +2. **Compose only documented Fold operators:** rejected by the runnable + correctness mismatch. The graph relation cannot be correlated to revocation + roots through arbitrary depth. +3. **Write a custom recursive `Push` node:** technically possible, rejected as a + BogKit selection because it means implementing the dynamic reachability, + witness, cycle, storage, and recovery engine from scratch. +4. **Use Fold only as a local approved/blocked cache:** rejected because it leaves + computation in PostgreSQL and adds a second publication/reconciliation + boundary without solving the core problem. +5. **Use ANNy or ESE:** rejected as irrelevant; approximate search and embeddings + cannot safely decide exact provenance reachability. +6. **Recommended direction:** preserve PostgreSQL authority and prototype an + incremental reverse-dependency/affected-generation design with atomic + generation publication and specified deterministic explanations. Revisit BogKit only after + it has a supported recursive dataflow/join facility and an authority-safe + PostgreSQL integration story. + +## Final uncertainty statement + +The trial proves that its deliberately incomplete one-hop negative control is +unsafe and identifies no supported public join or recursive-reachability +abstraction. It does not prove that a concrete Fold composition is incorrect or +that a bespoke Rust graph engine built as a custom Fold node could never meet the +numeric targets. It also does not measure the 500k/5m workload, concurrent update +latency, peak memory, or durable-state ratio. Those tests would only be justified +after selecting or building a correct incremental graph component. diff --git a/developer-simulation/runs/2026-08-03--provenance-revocation-impact/src/main.rs b/developer-simulation/runs/2026-08-03--provenance-revocation-impact/src/main.rs new file mode 100644 index 0000000..884968c --- /dev/null +++ b/developer-simulation/runs/2026-08-03--provenance-revocation-impact/src/main.rs @@ -0,0 +1,484 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{self, BufRead, Write}; +use std::path::Path; + +use fold::pipeline::terminal; +use fold::stream::Stream; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +enum Command { + Artifact { + id: String, + }, + Edge { + artifact: String, + dependency: String, + }, + Release { + id: String, + artifact: String, + }, + Revoke { + id: String, + }, + Query { + release: String, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +enum Fact { + Artifact { + id: String, + }, + Edge { + artifact: String, + dependency: String, + }, + Release { + id: String, + artifact: String, + }, + Revoke { + id: String, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Engine { + Candidate, + Reference, +} + +impl Engine { + fn parse(value: &str) -> Result { + match value { + "candidate" => Ok(Self::Candidate), + "reference" => Ok(Self::Reference), + _ => Err(format!( + "unknown engine {value:?}; use candidate or reference" + )), + } + } + + fn name(self) -> &'static str { + match self { + Self::Candidate => "one_hop_negative_control", + Self::Reference => "slow_reference", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct Decision { + decision: &'static str, + reason: &'static str, + path: Vec, +} + +impl Decision { + fn approved() -> Self { + Self { + decision: "approved", + reason: "complete", + path: Vec::new(), + } + } + + fn blocked(reason: &'static str, path: Vec) -> Self { + Self { + decision: "blocked", + reason, + path, + } + } +} + +#[derive(Debug, Serialize)] +struct Output<'a> { + engine: &'static str, + release: &'a str, + #[serde(flatten)] + decision: Decision, +} + +#[derive(Default)] +struct Model { + artifacts: BTreeSet, + edges: BTreeMap>, + releases: BTreeMap, + revoked: BTreeSet, +} + +impl Model { + fn add(&mut self, fact: Fact) { + match fact { + Fact::Artifact { id } => { + self.artifacts.insert(id); + } + Fact::Edge { + artifact, + dependency, + } => { + self.edges.entry(artifact).or_default().insert(dependency); + } + Fact::Release { id, artifact } => { + self.releases.insert(id, artifact); + } + Fact::Revoke { id } => { + self.revoked.insert(id); + } + } + } + + /// A deliberately narrow model of what the documented built-ins can + /// express without a cross-stream join or recursive fixed point. It + /// checks the release artifact and one edge hop only. + fn candidate_decision(&self, release: &str) -> Decision { + let Some(root) = self.releases.get(release) else { + return Decision::blocked("missing_release", vec![release.to_string()]); + }; + if !self.artifacts.contains(root) { + return Decision::blocked("missing_manifest", vec![root.clone()]); + } + if self.revoked.contains(root) { + return Decision::blocked("revoked", vec![root.clone()]); + } + + for dependency in self.edges.get(root).into_iter().flatten() { + if dependency == root { + return Decision::blocked("invalid_cycle", vec![root.clone(), dependency.clone()]); + } + if !self.artifacts.contains(dependency) { + return Decision::blocked( + "missing_manifest", + vec![root.clone(), dependency.clone()], + ); + } + if self.revoked.contains(dependency) { + return Decision::blocked("revoked", vec![root.clone(), dependency.clone()]); + } + } + + Decision::approved() + } + + fn reference_decision(&self, release: &str) -> Decision { + let Some(root) = self.releases.get(release) else { + return Decision::blocked("missing_release", vec![release.to_string()]); + }; + let mut stack = Vec::new(); + let mut complete = BTreeSet::new(); + self.visit(root, &mut stack, &mut complete) + .unwrap_or_else(Decision::approved) + } + + fn visit( + &self, + artifact: &str, + stack: &mut Vec, + complete: &mut BTreeSet, + ) -> Option { + if let Some(position) = stack.iter().position(|item| item == artifact) { + let mut path = stack.clone(); + path.push(artifact.to_string()); + debug_assert!(position < path.len()); + return Some(Decision::blocked("invalid_cycle", path)); + } + if complete.contains(artifact) { + return None; + } + + stack.push(artifact.to_string()); + if !self.artifacts.contains(artifact) { + return Some(Decision::blocked("missing_manifest", stack.clone())); + } + if self.revoked.contains(artifact) { + return Some(Decision::blocked("revoked", stack.clone())); + } + + for dependency in self.edges.get(artifact).into_iter().flatten() { + if let Some(decision) = self.visit(dependency, stack, complete) { + return Some(decision); + } + } + let removed = stack.pop(); + debug_assert_eq!(removed.as_deref(), Some(artifact)); + complete.insert(artifact.to_string()); + None + } +} + +fn fact_from_command(command: Command) -> Result { + match command { + Command::Artifact { id } => Ok(Fact::Artifact { id }), + Command::Edge { + artifact, + dependency, + } => Ok(Fact::Edge { + artifact, + dependency, + }), + Command::Release { id, artifact } => Ok(Fact::Release { id, artifact }), + Command::Revoke { id } => Ok(Fact::Revoke { id }), + Command::Query { .. } => Err("query is not a persistent fact".to_string()), + } +} + +fn load_model(stream: &Stream>) -> Model { + stream.rtx(|facts| { + let mut model = Model::default(); + for (fact, multiplicity) in facts.iter() { + if multiplicity > 0 { + model.add(fact); + } + } + model + }) +} + +fn crash_spec() -> Option<(String, usize)> { + let value = std::env::var("PROVENANCE_CRASH").ok()?; + let (phase, number) = value.split_once(':')?; + let number = number.parse().ok()?; + Some((phase.to_string(), number)) +} + +fn run(state_path: &Path, engine: Engine) -> Result<(), String> { + let mut stream = Stream::new(state_path, terminal::Bag::::new("facts")); + let stdin = io::stdin(); + let mut stdout = io::BufWriter::new(io::stdout().lock()); + let crash = crash_spec(); + let mut fact_number = 0usize; + + for (line_number, line) in stdin.lock().lines().enumerate() { + let line = line.map_err(|error| format!("read line {}: {error}", line_number + 1))?; + if line.trim().is_empty() { + continue; + } + let command: Command = serde_json::from_str(&line) + .map_err(|error| format!("parse line {}: {error}", line_number + 1))?; + match command { + Command::Query { release } => { + let model = load_model(&stream); + let decision = match engine { + Engine::Candidate => model.candidate_decision(&release), + Engine::Reference => model.reference_decision(&release), + }; + serde_json::to_writer( + &mut stdout, + &Output { + engine: engine.name(), + release: &release, + decision, + }, + ) + .map_err(|error| format!("write decision: {error}"))?; + writeln!(stdout).map_err(|error| format!("write newline: {error}"))?; + stdout + .flush() + .map_err(|error| format!("flush output: {error}"))?; + } + persistent => { + fact_number += 1; + let fact = fact_from_command(persistent)?; + stream.wtx(|tx| { + tx.insert(&fact); + if crash.as_ref() == Some(&("before_commit".to_string(), fact_number)) { + panic!("injected crash before commit at fact {fact_number}"); + } + }); + if crash.as_ref() == Some(&("after_commit".to_string(), fact_number)) { + panic!("injected crash after commit at fact {fact_number}"); + } + } + } + } + Ok(()) +} + +fn generate() -> Result<(), String> { + const CORPUS: &[&str] = &[ + r#"{"op":"artifact","id":"app"}"#, + r#"{"op":"artifact","id":"middle"}"#, + r#"{"op":"artifact","id":"revoked-base"}"#, + r#"{"op":"artifact","id":"cycle-a"}"#, + r#"{"op":"artifact","id":"cycle-b"}"#, + r#"{"op":"edge","artifact":"app","dependency":"middle"}"#, + r#"{"op":"edge","artifact":"app","dependency":"middle"}"#, + r#"{"op":"edge","artifact":"middle","dependency":"revoked-base"}"#, + r#"{"op":"edge","artifact":"cycle-a","dependency":"cycle-b"}"#, + r#"{"op":"edge","artifact":"cycle-b","dependency":"cycle-a"}"#, + r#"{"op":"release","id":"transitive","artifact":"app"}"#, + r#"{"op":"release","id":"cyclic","artifact":"cycle-a"}"#, + r#"{"op":"release","id":"unknown","artifact":"missing-root"}"#, + r#"{"op":"revoke","id":"revoked-base"}"#, + r#"{"op":"query","release":"transitive"}"#, + r#"{"op":"query","release":"cyclic"}"#, + r#"{"op":"query","release":"unknown"}"#, + ]; + let mut stdout = io::BufWriter::new(io::stdout().lock()); + for line in CORPUS { + writeln!(stdout, "{line}").map_err(|error| format!("write corpus: {error}"))?; + } + Ok(()) +} + +fn usage(program: &str) -> String { + format!("usage:\n {program} generate\n {program} run ") +} + +fn main() { + let args: Vec = std::env::args().collect(); + let result = match args.as_slice() { + [_, command] if command == "generate" => generate(), + [_, command, path, engine] if command == "run" => { + Engine::parse(engine).and_then(|engine| run(Path::new(path), engine)) + } + [program, ..] => Err(usage(program)), + [] => Err("missing program name".to_string()), + }; + if let Err(error) = result { + eprintln!("{error}"); + std::process::exit(2); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn model(facts: &[Fact]) -> Model { + let mut model = Model::default(); + for fact in facts { + model.add(fact.clone()); + } + model + } + + fn artifact(id: &str) -> Fact { + Fact::Artifact { id: id.to_string() } + } + + fn edge(artifact: &str, dependency: &str) -> Fact { + Fact::Edge { + artifact: artifact.to_string(), + dependency: dependency.to_string(), + } + } + + fn release(id: &str, artifact: &str) -> Fact { + Fact::Release { + id: id.to_string(), + artifact: artifact.to_string(), + } + } + + fn revoke(id: &str) -> Fact { + Fact::Revoke { id: id.to_string() } + } + + #[test] + fn one_hop_candidate_misses_transitive_revocation() { + let fixture = model(&[ + artifact("app"), + artifact("middle"), + artifact("base"), + edge("app", "middle"), + edge("middle", "base"), + release("prod", "app"), + revoke("base"), + ]); + assert_eq!(fixture.candidate_decision("prod"), Decision::approved()); + assert_eq!( + fixture.reference_decision("prod"), + Decision::blocked( + "revoked", + vec!["app".to_string(), "middle".to_string(), "base".to_string()] + ) + ); + } + + #[test] + fn reference_blocks_missing_transitive_manifest() { + let fixture = model(&[ + artifact("app"), + artifact("middle"), + edge("app", "middle"), + edge("middle", "absent"), + release("prod", "app"), + ]); + assert_eq!( + fixture.reference_decision("prod"), + Decision::blocked( + "missing_manifest", + vec![ + "app".to_string(), + "middle".to_string(), + "absent".to_string() + ] + ) + ); + } + + #[test] + fn reference_blocks_cycle_and_reports_stable_path() { + let fixture = model(&[ + artifact("app"), + artifact("a"), + artifact("b"), + edge("app", "a"), + edge("a", "b"), + edge("b", "a"), + release("prod", "app"), + ]); + assert_eq!( + fixture.reference_decision("prod"), + Decision::blocked( + "invalid_cycle", + vec![ + "app".to_string(), + "a".to_string(), + "b".to_string(), + "a".to_string() + ] + ) + ); + } + + #[test] + fn reference_is_independent_of_fact_order_and_duplicate_edges() { + let mut facts = vec![ + artifact("app"), + artifact("a"), + artifact("z"), + edge("app", "z"), + edge("app", "a"), + edge("app", "a"), + release("prod", "app"), + revoke("a"), + revoke("z"), + ]; + let forward = model(&facts).reference_decision("prod"); + facts.reverse(); + let reverse = model(&facts).reference_decision("prod"); + assert_eq!(forward, reverse); + assert_eq!( + forward, + Decision::blocked("revoked", vec!["app".to_string(), "a".to_string()]) + ); + } + + #[test] + fn reference_approves_complete_acyclic_graph() { + let fixture = model(&[ + artifact("app"), + artifact("base"), + edge("app", "base"), + release("prod", "app"), + ]); + assert_eq!(fixture.reference_decision("prod"), Decision::approved()); + } +} diff --git a/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/Cargo.toml b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/Cargo.toml new file mode 100644 index 0000000..7b04afc --- /dev/null +++ b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "mixed-version-contract-gate" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[[bin]] +name = "contract-gate" +path = "src/main.rs" + +[[bin]] +name = "generate" +path = "src/bin/generate.rs" diff --git a/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/README.md b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/README.md new file mode 100644 index 0000000..35f9d27 --- /dev/null +++ b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/README.md @@ -0,0 +1,92 @@ +# Mixed-version contract gate trial + +This standalone Rust CLI checks every producer-version × consumer-version pair +per permitted topology relationship. It implements only this deliberately small +contract language: + +- `type`: `string`, `integer`, `array`, or `object` +- string `enum` +- integer `minimum` and `maximum` (inclusive, signed 64-bit) +- array `items` +- object `properties`, `required`, and boolean `additionalProperties` +- `default` on any supported schema; a required property with a valid default + may be absent because the receiver materializes that default + +Anything else is `review-required`, with a source and semantic JSON-pointer +location. Defaults do not otherwise assert application semantics. + +## Input files + +`contracts.json` and `candidate.json` contain: + +```json +{"contracts":[{"service":"api","topic":"orders","version":3,"schema":{"type":"object","properties":{},"required":[],"additionalProperties":false}}]} +``` + +Candidate entries replace a base contract with the same service, topic, and +version. Identical duplicate entries are ignored; conflicting duplicates need +review. + +`topology.json` contains: + +```json +{"relationships":[{"topic":"orders","producer":"api","consumer":"worker"}]} +``` + +`fleet.json` contains the complete permitted version set for each service: + +```json +{"services":{"api":[1,2,3],"worker":[1,2,3]}} +``` + +## Run + +Run from the BogKit repository root. Generated inputs and build output stay +outside the archive. + +```console +export CARGO_TARGET_DIR=/private/tmp/mixed-version-contract-target +DEMO_DIR="$(mktemp -d /private/tmp/mixed-version-contract-demo.XXXXXX)" +cargo run --offline --locked --manifest-path developer-simulation/Cargo.toml \ + -p mixed-version-contract-gate --bin generate -- demo "$DEMO_DIR" +cargo run --offline --locked --manifest-path developer-simulation/Cargo.toml \ + -p mixed-version-contract-gate --bin contract-gate -- \ + "$DEMO_DIR/contracts.json" \ + "$DEMO_DIR/topology.json" \ + "$DEMO_DIR/fleet.json" \ + "$DEMO_DIR/candidate.json" +``` + +Exit status is 0 for `allow`, 1 for `block`, and 2 for `review-required` or +input/usage errors. JSON output is deterministic. A witness is ranked first by +JSON structural size, then encoded byte length, then canonical JSON bytes. + +The CLI uses an incremental path: it reuses the base result for unaffected +pairs and reevaluates every pair touched by a candidate key. Tests compare that +result exactly with a fresh full evaluation. + +## Verification + +```console +export CARGO_TARGET_DIR=/private/tmp/mixed-version-contract-target +cargo fmt --manifest-path developer-simulation/Cargo.toml \ + -p mixed-version-contract-gate -- --check +cargo clippy --offline --locked --manifest-path developer-simulation/Cargo.toml \ + -p mixed-version-contract-gate --all-targets -- -D warnings +cargo test --offline --locked --manifest-path developer-simulation/Cargo.toml \ + -p mixed-version-contract-gate +python3 developer-simulation/runs/2026-08-04--mixed-version-contract-gate/oracle.py \ + developer-simulation/runs/2026-08-04--mixed-version-contract-gate/fixtures/semantic_cases.json +``` + +Generate the stated workload with: + +```console +WORKLOAD_DIR="$(mktemp -d /private/tmp/mixed-version-contract-workload.XXXXXX)" +cargo run --release --offline --locked \ + --manifest-path developer-simulation/Cargo.toml \ + -p mixed-version-contract-gate --bin generate -- workload "$WORKLOAD_DIR" +``` + +It contains exactly 300 services, 120 topics, 1,800 contracts, 12,000 unique +relationships, three permitted versions per service, and 25 candidate entries. diff --git a/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/TRIAL_REPORT.md b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/TRIAL_REPORT.md new file mode 100644 index 0000000..9b4a39d --- /dev/null +++ b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/TRIAL_REPORT.md @@ -0,0 +1,273 @@ +# Trial notes — mixed-version contract gate + +## Skeptical review and coordinator correction + +The independent reviewer reproduced the demo, 64-case oracle agreement, and +full 108,000-pair workload, but found a high-severity parser defect before +archival: duplicate raw JSON member names used last-value-wins parsing and could +turn `{"type":"string","type":"integer"}` into a false allow. The +coordinator replaced that path with duplicate-rejecting parsing at every JSON +nesting level and added regressions for root objects, contract records, schemas, +topology, fleet, and candidates. All 8 corrected tests and strict lint pass. + +The reviewer also ran the previously missing full-scale reversed/identical- +duplicate input and a two-direction cyclic relationship. Both passed with +byte-identical or structurally bounded results. The final nested-workspace +workload again evaluated 108,000 pairs with 237 issues and no review issues in +0.52 seconds, using 119,144,448 bytes maximum RSS on this host. These are +workload-specific results, not a formal proof, portability guarantee, or +comparison with a runnable existing-system contract gate. Earlier corrected +standalone and reviewer runs completed in 0.24-0.25 seconds with materially +similar memory. The no-fit conclusion remains; no BogKit defect was +demonstrated. + +The remainder preserves the blind developer's trail. This reviewed correction +controls where an initial claim differs. + +Date: 2026-08-04 +Persona: deployment-platform developer; production Go, intermediate Rust +Checkout: sanitized detached current-main checkout at `/private/tmp/bogkit-sim-2026-08-04-a` +Trial: `/private/tmp/bogkit-sim-2026-08-04-a/trial` + +## Finishing criteria used + +The trial was considered complete only if it had a runnable four-input Rust CLI, +deterministic mixed-version evaluation and witnesses, strict review behavior for +unsupported/malformed input, a generator, at least 60 fixed semantic cases, an +independent checker, shuffle/duplicate and incremental/full checks, a verified +demo, formatting/lint/test passes, and an actually measured stated workload. + +## Exact discovery order and first-use friction + +No prior report, simulation directory, other temporary checkout, or other +developer's work was inspected. + +1. `README.md`, via `pwd && sed -n '1,240p' README.md`. + - Learned that the recommended start is `./scripts/new-project.sh`, and that + the four public examples are starter, timeseries, chat, and search. +2. `examples/starter/src/main.rs` and its file list, via + `rg --files examples/starter | sort` and `sed -n '1,240p' ...`. +3. `examples/timeseries/src/main.rs`, using the same bounded file-list/read + pattern. +4. `examples/chat/src/main.rs`, using the same bounded pattern. +5. `examples/search/src/main.rs`, using the same bounded pattern. +6. Runnable baseline attempt: `cargo run -p starter --offline`. + - Failed after 6.2 seconds while the `ese` build script attempted to download + `model.safetensors`; DNS was unavailable. This happened even though starter + does not use ESE in its source. +7. Baseline retry with network: `cargo run -p starter`. + - Passed. Build finished in 6.88 seconds and the demo printed three entries, + then two after retracting `peat`. +8. Root `Cargo.toml`, `examples/starter/Cargo.toml`, and a bounded Fold file list. + - Confirmed starter directly declares `anny`, `ese`, and `fold`, and the + workspace uses `examples/*` members. +9. `fold/src/lib.rs`, `fold/src/stream/mod.rs`, and + `fold/src/pipeline/mod.rs`. +10. `scripts/new-project.sh`, `fold/src/stream/unkeyed.rs`, and + `fold/src/pipeline/terminal/table.rs`. + - Confirmed the generator always adds all three local dependencies and Fold + provides persistent delta streams/materialized sinks, not schema-language + parsing or inclusion. + +The public README was useful for reaching a runnable example quickly. The main +friction was that the advertised smallest example and generated-project template +pull in the heavyweight ESE build artifact regardless of use. + +## Fit decision + +Decision: **do not use a BogKit component in this prototype**. + +The required work is a bounded, pure, order-independent language-inclusion pass +over immutable manifests. Fold's value is durable incremental materialization of +runtime deltas. Using it here would add a persistent store, transaction lifecycle, +serialization constraints, and the observed workspace/model setup cost without +removing the hard parts: strict parsing, recursive inclusion, minimal witnesses, +and exact diagnostics. Candidate incrementality is cheaper and clearer as a +sorted in-memory pair-result map that selectively reevaluates changed keys. + +This is a poor product fit, not evidence of a Fold correctness defect. No Anny or +ESE capability relates to the problem. + +## Prototype delivered + +- Standalone Rust 2024 crate with only `serde` and `serde_json`. +- CLI: `contract-gate `. +- Supported schema subset: required/optional object properties, strings, finite + string enums, bounded/unbounded signed integers, defaults, arrays, and + open/closed objects. +- Every unique topology relationship expands to the full cross product of the + producer's and consumer's permitted fleet versions. +- Candidate entries replace matching immutable base keys. The implementation + reuses unaffected base pair results and reevaluates every touched pair. +- Unsupported keywords/types and malformed structures return `review-required`. + Contract diagnostics use a stable semantic identity pointer such as + `/contracts/service=a/topic=t/version=1/schema/pattern`; malformed JSON uses + parser line and column. +- Conflicting contract-array duplicates and all duplicate JSON members require + review. Identical contracts, duplicate + relationships, and duplicate fleet versions are normalized. +- Block issues are sorted by topic/service/version pair. Per-pair witness choice + is deterministic: fewest JSON nodes, shortest canonical encoding, canonical + byte order, then rule/path. +- Generator modes: + - `demo`: six contracts, one relationship, nine version pairs, one seeded + required-field change. + - `workload`: exactly 300 services, 120 live topics, 1,800 immutable contracts, + 12,000 unique relationships, three versions per service, and 25 candidates. +- Fixed 64-case fixture catalog plus a structurally independent Python oracle. + +Default semantics are explicit and deliberately narrow: a valid default permits +a receiver to materialize an absent required property. Different default values +are not treated as breakage because arbitrary application semantics are a stated +non-goal. + +## Commands and observed results + +Build and checks: + +- `cargo check --all-targets --offline` + - Initial compile exposed one Rust borrow error in the prototype; fixed. + - Final run passed. +- `cargo fmt --all -- --check` + - Passed after formatting. +- `cargo clippy --all-targets --offline -- -D warnings` + - Initial run found five library style warnings, then one generator loop + warning. All were fixed; final run passed with warnings denied. +- `cargo test --offline` + - Final reviewed result: 8 tests passed, 0 failed. + - Includes 64 semantic fixture cases, exact diagnostic locations, + producer-accepted/consumer-rejected witness validation, malformed default, + unsupported construct behavior, shuffle/identical-duplicate stability, and + incremental/full equality. +- `python3 oracle.py fixtures/semantic_cases.json` + - `independent oracle verified 64 semantic cases`. + +Demo: + +- `cargo run --offline --bin generate -- demo generated/demo` + - Generated all four inputs. +- `cargo run --offline --bin contract-gate -- generated/demo/contracts.json generated/demo/topology.json generated/demo/fleet.json generated/demo/candidate.json` + - Exit 1 (`block`), evaluated all 9 pairs, returned exactly 3 issues: producer + versions 1, 2, and 3 against consumer version 3. Each rule was + `required-field-missing` at `/region`, witness `{"id":0}`. + +Generated workload validation: + +- Generator assertions and `jq` checks confirmed 300 services, 120 contract and + topology topics, 1,800 contracts, 12,000 relationships, every fleet entry with + 3 versions, and 25 candidates. +- Release build: `cargo build --release --offline --bins` passed. +- Timed run (outside the restricted process-accounting sandbox): + `/usr/bin/time -l target/release/contract-gate ...` + - Exit 1 (`block`), as seeded. + - 0.24 seconds real, 0.23 seconds user. + - 118,784,000 bytes maximum resident set size = about 113.3 MiB. + - 99,697,048 bytes reported as peak memory footprint. + - Both goals passed: under 5 seconds and under 128 MiB in one process. + - 108,000 pairs evaluated (`12,000 × 3 × 3`). + - 237 issues, all the expected incoming producer-version combinations against + the one narrowed consumer contract; 0 review issues. +- A repeat run produced the identical SHA-256 + `d5cbec30584c237b56d03a507292ffb4772234c34be1f47a5ebbb3436e4fd5d7`. +- The 25-candidate incremental result was normalized and diffed against a full + evaluation of the merged 1,800-contract manifest; `diff` exited 0. + +## Categorized findings + +### Documentation gap — moderate severity, high confidence + +The README calls starter the smallest Fold database and recommends the project +script, but neither explains that the generated manifest unconditionally adds +ANNy and ESE or that ESE's build downloads a model. Reproduction: +`cargo run -p starter --offline` in a clean target directory. Smallest plausible +improvement: make new-project dependencies opt-in and remove unused Anny/ESE +dependencies from starter; document the optional ESE artifact. + +### Performance/setup problem — moderate severity, high confidence + +The smallest baseline compiled ESE and required its model despite no ESE use in +starter source. This is baseline packaging/build behavior, not a contract-gate +defect and not evidence about Fold runtime performance. Smallest improvement: +remove the unused direct dependencies from the starter manifest. + +### Poor product fit — informational severity, high confidence + +Fold's persistent delta/materialized-view abstraction does not supply schema +language inclusion and adds state that this read-only bounded gate does not need. +Reproduction path: compare the documented `Stream`/`Push`/terminal interface to +the four immutable inputs and deterministic batch output. Smallest improvement is +documentation showing when a plain in-memory pass is preferable; no new Fold API +is justified by this trial. + +### Missing capability — informational severity, high confidence + +No inspected BogKit component parses this schema subset, proves producer-language +inclusion, or constructs counterexamples. This is outside the currently described +BogKit scope, so it is not classified as a defect. The smallest plausible product +change would be a separate contract-analysis crate only if this use case becomes +intentional product scope. + +### API friction — low severity, medium confidence + +Public examples note that closure-containing pipeline types are hard to name and +therefore use macros for snapshot helpers. This did not block the baseline and was +not exercised in the prototype. A named/boxed pipeline-reader ergonomics example +could help, but there is insufficient evidence here to recommend an API change. + +### Prototype correctness defects found and fixed + +- One internal borrow conflict prevented the first compile. +- One fixture initially expected the typed-open-object rule while its consumer was + closed; the smaller valid witness correctly exercised the closed-object rule. + The fixture was corrected to isolate the intended open-object case. +- Clippy findings were mechanical and fixed. No known defect remained after the + final verification set. + +## Consequential-choice decision audit + +1. **Use Fold or stay standalone?** Chose standalone after running the baseline + and reading Fold's public stream/pipeline interfaces. Consequence: minimal + dependencies and no persistence; the trial does not evaluate whether Fold + could cache results across separate CI jobs. +2. **What does `default` mean?** Chose receiver materialization only. Consequence: + adding a required field with a valid receiver default is allowed, while + removing that protection can block. Default-value semantic changes remain out + of scope. +3. **Fail open or require review?** Chose review for every unsupported keyword, + unsupported type, invalid bound/default, missing active contract, conflicting + duplicate, or malformed input. No unsupported construct can produce allow. +4. **One issue or all issues?** Chose one smallest deterministic witness per + breaking version pair, while returning every breaking pair. Consequence: output + identifies rollout combinations without multiplying redundant rules per pair. +5. **Incremental representation?** Chose an ordered pair-result cache and touched + key reevaluation. Consequence: simple exact equivalence with full evaluation; + no durable cross-run cache. +6. **Output order?** Chose sorted semantic identities rather than source offsets. + Consequence: shuffle/duplicate stability while diagnostics retain stable exact + contract/schema pointers. + +## Unresolved uncertainty and limits + +- The 64 fixed cases and independent oracle are broad, but not an exhaustive + formal proof of the recursive schema inclusion implementation. +- Minimality is exact under the documented ranking for the supported mismatch + constructors; no separate exhaustive witness enumerator was built for arbitrary + deeply nested schemas. +- Shuffle/duplicate stability was executed in the test fixture; skeptical review + also reversed and identically duplicated the full input and received + byte-identical output. +- Cyclic relationships are structurally bounded because relationships are expanded + independently with no graph traversal. Skeptical review added the reverse + relationship and observed 18 evaluated pairs with the same three issues; this + is not evidence of graph-level cyclic semantics. +- Peak RSS passed by about 14.7 MiB. Only the recorded full-size process-accounting + run is claimed; no multi-run memory distribution was measured. +- JSON numbers outside signed 64-bit integer bounds require review by design. + +## Separation of claims + +The ESE download and unused-dependency friction belongs to the documented BogKit +starter/new-project baseline. Compile issues and fixture correction belonged only +to this prototype and were fixed. The measured timing/memory and semantic results +apply only to the generated workload and this machine/run; they are not general +BogKit performance claims. diff --git a/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/fixtures/semantic_cases.json b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/fixtures/semantic_cases.json new file mode 100644 index 0000000..6f351fd --- /dev/null +++ b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/fixtures/semantic_cases.json @@ -0,0 +1,104 @@ +{ + "schemas": { + "s": {"type":"string"}, + "ea": {"type":"string","enum":["a"]}, + "eab": {"type":"string","enum":["a","b"]}, + "ebc": {"type":"string","enum":["b","c"]}, + "i": {"type":"integer"}, + "i0_10": {"type":"integer","minimum":0,"maximum":10}, + "i1_9": {"type":"integer","minimum":1,"maximum":9}, + "in10_n1": {"type":"integer","minimum":-10,"maximum":-1}, + "in5_5": {"type":"integer","minimum":-5,"maximum":5}, + "i10_20": {"type":"integer","minimum":10,"maximum":20}, + "i0": {"type":"integer","minimum":0,"maximum":0}, + "a_s": {"type":"array","items":{"type":"string"}}, + "a_ea": {"type":"array","items":{"type":"string","enum":["a"]}}, + "a_eab": {"type":"array","items":{"type":"string","enum":["a","b"]}}, + "a_i": {"type":"array","items":{"type":"integer"}}, + "a_i0_10": {"type":"array","items":{"type":"integer","minimum":0,"maximum":10}}, + "a_i1_9": {"type":"array","items":{"type":"integer","minimum":1,"maximum":9}}, + "o_closed": {"type":"object","properties":{},"required":[],"additionalProperties":false}, + "o_open": {"type":"object","properties":{},"required":[],"additionalProperties":true}, + "o_x_req_s": {"type":"object","properties":{"x":{"type":"string"}},"required":["x"],"additionalProperties":false}, + "o_x_opt_s": {"type":"object","properties":{"x":{"type":"string"}},"required":[],"additionalProperties":false}, + "o_x_opt_s_open": {"type":"object","properties":{"x":{"type":"string"}},"required":[],"additionalProperties":true}, + "o_x_req_s_def": {"type":"object","properties":{"x":{"type":"string","default":""}},"required":["x"],"additionalProperties":false}, + "o_x_req_ea": {"type":"object","properties":{"x":{"type":"string","enum":["a"]}},"required":["x"],"additionalProperties":false}, + "o_x_req_eab": {"type":"object","properties":{"x":{"type":"string","enum":["a","b"]}},"required":["x"],"additionalProperties":false}, + "o_x_req_i": {"type":"object","properties":{"x":{"type":"integer"}},"required":["x"],"additionalProperties":false}, + "o_x_req_i_def0": {"type":"object","properties":{"x":{"type":"integer","default":0}},"required":["x"],"additionalProperties":false}, + "o_x_req_i0_10": {"type":"object","properties":{"x":{"type":"integer","minimum":0,"maximum":10}},"required":["x"],"additionalProperties":false}, + "o_x_req_i1_9": {"type":"object","properties":{"x":{"type":"integer","minimum":1,"maximum":9}},"required":["x"],"additionalProperties":false}, + "nested_open": {"type":"object","properties":{"x":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"required":["x"],"additionalProperties":false}, + "nested_closed": {"type":"object","properties":{"x":{"type":"object","properties":{},"required":[],"additionalProperties":false}},"required":["x"],"additionalProperties":false}, + "o_x_req_y_opt": {"type":"object","properties":{"x":{"type":"string"},"y":{"type":"string"}},"required":["x"],"additionalProperties":false}, + "o_xy_req": {"type":"object","properties":{"x":{"type":"string"},"y":{"type":"string"}},"required":["x","y"],"additionalProperties":false}, + "o_xy_req_y_def": {"type":"object","properties":{"x":{"type":"string"},"y":{"type":"string","default":""}},"required":["x","y"],"additionalProperties":false} + }, + "cases": [ + {"name":"01 unconstrained strings","producer":"s","consumer":"s","expected":"allow"}, + {"name":"02 enum into string","producer":"ea","consumer":"s","expected":"allow"}, + {"name":"03 equal one-value enum","producer":"ea","consumer":"ea","expected":"allow"}, + {"name":"04 narrower producer enum","producer":"ea","consumer":"eab","expected":"allow"}, + {"name":"05 wider producer enum","producer":"eab","consumer":"ea","expected":"block","rule":"enum-value-rejected"}, + {"name":"06 unconstrained into enum","producer":"s","consumer":"eab","expected":"block","rule":"enum-value-rejected"}, + {"name":"07 overlapping enum right","producer":"ebc","consumer":"eab","expected":"block","rule":"enum-value-rejected"}, + {"name":"08 overlapping enum left","producer":"eab","consumer":"ebc","expected":"block","rule":"enum-value-rejected"}, + {"name":"09 unconstrained integers","producer":"i","consumer":"i","expected":"allow"}, + {"name":"10 bounded into integer","producer":"i0_10","consumer":"i","expected":"allow"}, + {"name":"11 equal bounds","producer":"i0_10","consumer":"i0_10","expected":"allow"}, + {"name":"12 narrower bounds","producer":"i1_9","consumer":"i0_10","expected":"allow"}, + {"name":"13 narrower both sides","producer":"i0_10","consumer":"i1_9","expected":"block","rule":"integer-below-minimum"}, + {"name":"14 raised minimum","producer":"i0_10","consumer":"i1_9","expected":"block","rule":"integer-below-minimum"}, + {"name":"15 lowered maximum","producer":"i0_10","consumer":"i0","expected":"block","rule":"integer-above-maximum"}, + {"name":"16 unconstrained into bounds","producer":"i","consumer":"i0_10","expected":"block","rule":"integer-below-minimum"}, + {"name":"17 negative lower narrowing","producer":"in10_n1","consumer":"in5_5","expected":"block","rule":"integer-below-minimum"}, + {"name":"18 negative upper narrowing","producer":"in5_5","consumer":"in10_n1","expected":"block","rule":"integer-above-maximum"}, + {"name":"19 disjoint high producer","producer":"i10_20","consumer":"i0_10","expected":"block","rule":"integer-above-maximum"}, + {"name":"20 zero below consumer","producer":"i0","consumer":"i1_9","expected":"block","rule":"integer-below-minimum"}, + {"name":"21 positive above zero","producer":"i1_9","consumer":"i0","expected":"block","rule":"integer-above-maximum"}, + {"name":"22 negative range into integer","producer":"in10_n1","consumer":"i","expected":"allow"}, + {"name":"23 string versus integer","producer":"s","consumer":"i","expected":"block","rule":"type-mismatch"}, + {"name":"24 integer versus string","producer":"i","consumer":"s","expected":"block","rule":"type-mismatch"}, + {"name":"25 array versus object","producer":"a_s","consumer":"o_open","expected":"block","rule":"type-mismatch"}, + {"name":"26 object versus array","producer":"o_closed","consumer":"a_s","expected":"block","rule":"type-mismatch"}, + {"name":"27 string versus array","producer":"s","consumer":"a_s","expected":"block","rule":"type-mismatch"}, + {"name":"28 array versus string","producer":"a_s","consumer":"s","expected":"block","rule":"type-mismatch"}, + {"name":"29 object versus string","producer":"o_closed","consumer":"s","expected":"block","rule":"type-mismatch"}, + {"name":"30 integer versus object","producer":"i","consumer":"o_open","expected":"block","rule":"type-mismatch"}, + {"name":"31 object versus integer","producer":"o_closed","consumer":"i","expected":"block","rule":"type-mismatch"}, + {"name":"32 array item integer versus string","producer":"a_i","consumer":"a_s","expected":"block","rule":"type-mismatch"}, + {"name":"33 array item string versus integer","producer":"a_s","consumer":"a_i","expected":"block","rule":"type-mismatch"}, + {"name":"34 equal closed objects","producer":"o_closed","consumer":"o_closed","expected":"allow"}, + {"name":"35 closed producer open consumer","producer":"o_closed","consumer":"o_open","expected":"allow"}, + {"name":"36 equal open objects","producer":"o_open","consumer":"o_open","expected":"allow"}, + {"name":"37 open producer closed consumer","producer":"o_open","consumer":"o_closed","expected":"block","rule":"closed-object-rejects-property"}, + {"name":"38 equal required field","producer":"o_x_req_s","consumer":"o_x_req_s","expected":"allow"}, + {"name":"39 required into optional","producer":"o_x_req_s","consumer":"o_x_opt_s","expected":"allow"}, + {"name":"40 optional into required","producer":"o_x_opt_s","consumer":"o_x_req_s","expected":"block","rule":"required-field-missing"}, + {"name":"41 optional into defaulted required","producer":"o_x_opt_s","consumer":"o_x_req_s_def","expected":"allow"}, + {"name":"42 producer can omit defaulted required","producer":"o_x_req_s_def","consumer":"o_x_req_s","expected":"block","rule":"required-field-missing"}, + {"name":"43 required extra into closed","producer":"o_x_req_s","consumer":"o_closed","expected":"block","rule":"closed-object-rejects-property"}, + {"name":"44 optional extra into closed","producer":"o_x_opt_s","consumer":"o_closed","expected":"block","rule":"closed-object-rejects-property"}, + {"name":"45 optional extra into open","producer":"o_x_opt_s","consumer":"o_open","expected":"allow"}, + {"name":"46 open producer can violate typed optional","producer":"o_open","consumer":"o_x_opt_s_open","expected":"block","rule":"open-object-property-unconstrained"}, + {"name":"47 open producer can omit required","producer":"o_open","consumer":"o_x_req_s","expected":"block","rule":"required-field-missing"}, + {"name":"48 nested narrow enum","producer":"o_x_req_ea","consumer":"o_x_req_eab","expected":"allow"}, + {"name":"49 nested wider enum","producer":"o_x_req_eab","consumer":"o_x_req_ea","expected":"block","rule":"enum-value-rejected"}, + {"name":"50 nested raised minimum","producer":"o_x_req_i0_10","consumer":"o_x_req_i1_9","expected":"block","rule":"integer-below-minimum"}, + {"name":"51 nested narrower integer","producer":"o_x_req_i1_9","consumer":"o_x_req_i0_10","expected":"allow"}, + {"name":"52 empty object missing required","producer":"o_closed","consumer":"o_x_req_s","expected":"block","rule":"required-field-missing"}, + {"name":"53 empty object uses default","producer":"o_closed","consumer":"o_x_req_s_def","expected":"allow"}, + {"name":"54 equal string arrays","producer":"a_s","consumer":"a_s","expected":"allow"}, + {"name":"55 narrow enum array","producer":"a_ea","consumer":"a_eab","expected":"allow"}, + {"name":"56 wide enum array","producer":"a_eab","consumer":"a_ea","expected":"block","rule":"enum-value-rejected"}, + {"name":"57 array raised minimum","producer":"a_i0_10","consumer":"a_i1_9","expected":"block","rule":"integer-below-minimum"}, + {"name":"58 array narrower range","producer":"a_i1_9","consumer":"a_i0_10","expected":"allow"}, + {"name":"59 unconstrained string array into enum","producer":"a_s","consumer":"a_eab","expected":"block","rule":"enum-value-rejected"}, + {"name":"60 nested open into closed","producer":"nested_open","consumer":"nested_closed","expected":"block","rule":"closed-object-rejects-property"}, + {"name":"61 nested closed into open","producer":"nested_closed","consumer":"nested_open","expected":"allow"}, + {"name":"62 newly required second field","producer":"o_x_req_y_opt","consumer":"o_xy_req","expected":"block","rule":"required-field-missing"}, + {"name":"63 defaulted second field","producer":"o_x_req_y_opt","consumer":"o_xy_req_y_def","expected":"allow"}, + {"name":"64 consumer optional field accepts subset","producer":"o_x_req_s","consumer":"o_x_req_y_opt","expected":"allow"} + ] +} diff --git a/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/oracle.py b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/oracle.py new file mode 100644 index 0000000..94c6e34 --- /dev/null +++ b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/oracle.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Independent, deliberately small semantic oracle for the fixture catalog. + +This does not invoke, import, or reproduce the Rust parser. It reasons over +already-valid fixture schemas and checks language inclusion by structural rules. +""" + +import json +import sys +from pathlib import Path + + +def first_break(producer, consumer): + if producer["type"] != consumer["type"]: + return "type-mismatch" + + kind = producer["type"] + if kind == "string": + consumer_enum = consumer.get("enum") + if consumer_enum is None: + return None + producer_enum = producer.get("enum") + if producer_enum is None or not set(producer_enum).issubset(consumer_enum): + return "enum-value-rejected" + return None + + if kind == "integer": + producer_min = producer.get("minimum") + producer_max = producer.get("maximum") + consumer_min = consumer.get("minimum") + consumer_max = consumer.get("maximum") + if consumer_min is not None and (producer_min is None or producer_min < consumer_min): + return "integer-below-minimum" + if consumer_max is not None and (producer_max is None or producer_max > consumer_max): + return "integer-above-maximum" + return None + + if kind == "array": + return first_break(producer["items"], consumer["items"]) + + if kind == "object": + producer_properties = producer.get("properties", {}) + consumer_properties = consumer.get("properties", {}) + producer_required = set(producer.get("required", [])) + consumer_required = set(consumer.get("required", [])) + + for name in sorted(consumer_required): + consumer_has_default = "default" in consumer_properties[name] + producer_always_has = ( + name in producer_required + and name in producer_properties + and "default" not in producer_properties[name] + ) + if not consumer_has_default and not producer_always_has: + return "required-field-missing" + + for name in sorted(producer_properties): + if name in consumer_properties: + nested = first_break(producer_properties[name], consumer_properties[name]) + if nested: + return nested + elif not consumer.get("additionalProperties", True): + return "closed-object-rejects-property" + + if producer.get("additionalProperties", True): + if not consumer.get("additionalProperties", True): + return "closed-object-rejects-property" + if any(name not in producer_properties for name in consumer_properties): + return "open-object-property-unconstrained" + return None + + raise AssertionError(f"fixture uses unsupported type {kind!r}") + + +def main(): + if len(sys.argv) != 2: + raise SystemExit("usage: oracle.py ") + fixture = json.loads(Path(sys.argv[1]).read_text()) + schemas = fixture["schemas"] + cases = fixture["cases"] + if len(cases) < 60: + raise AssertionError(f"fixture regression: only {len(cases)} cases") + + failures = [] + for case in cases: + rule = first_break(schemas[case["producer"]], schemas[case["consumer"]]) + actual = "allow" if rule is None else "block" + if actual != case["expected"] or (rule is not None and rule != case["rule"]): + failures.append( + f'{case["name"]}: expected {case["expected"]}/{case.get("rule")}, ' + f"oracle got {actual}/{rule}" + ) + if failures: + raise AssertionError("\n".join(failures)) + print(f"independent oracle verified {len(cases)} semantic cases") + + +if __name__ == "__main__": + main() diff --git a/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/src/bin/generate.rs b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/src/bin/generate.rs new file mode 100644 index 0000000..0830791 --- /dev/null +++ b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/src/bin/generate.rs @@ -0,0 +1,183 @@ +use std::collections::BTreeSet; +use std::env; +use std::fs; +use std::path::Path; +use std::process::ExitCode; + +use serde_json::{Value, json}; + +fn main() -> ExitCode { + let args: Vec = env::args().skip(1).collect(); + if args.len() != 2 || !matches!(args[0].as_str(), "demo" | "workload") { + eprintln!("usage: generate "); + return ExitCode::from(2); + } + let output = Path::new(&args[1]); + if let Err(error) = fs::create_dir_all(output) { + eprintln!("cannot create {}: {error}", output.display()); + return ExitCode::from(2); + } + let files = if args[0] == "demo" { + demo_files() + } else { + workload_files() + }; + for (name, value) in files { + let path = output.join(name); + let data = serde_json::to_vec_pretty(&value).expect("generated JSON is serializable"); + if let Err(error) = fs::write(&path, data) { + eprintln!("cannot write {}: {error}", path.display()); + return ExitCode::from(2); + } + } + println!("generated {} data in {}", args[0], output.display()); + ExitCode::SUCCESS +} + +fn demo_files() -> Vec<(&'static str, Value)> { + let producer = object_schema(false, false); + let consumer = object_schema(false, false); + let candidate = object_schema(true, false); + let mut contracts = Vec::new(); + for version in 1..=3 { + contracts.push(contract("producer", "orders", version, producer.clone())); + contracts.push(contract("consumer", "orders", version, consumer.clone())); + } + vec![ + ("contracts.json", json!({"contracts": contracts})), + ( + "topology.json", + json!({"relationships":[{"topic":"orders","producer":"producer","consumer":"consumer"}]}), + ), + ( + "fleet.json", + json!({"services":{"producer":[1,2,3],"consumer":[1,2,3]}}), + ), + ( + "candidate.json", + json!({"contracts":[contract("consumer", "orders", 3, candidate)]}), + ), + ] +} + +fn workload_files() -> Vec<(&'static str, Value)> { + let mut memberships = vec![BTreeSet::::new(); 300]; + for topics in memberships.iter_mut().take(150) { + topics.insert(0); + } + for (service, topics) in memberships.iter_mut().enumerate() { + topics.insert(1 + service % 119); + } + for (service, topics) in memberships.iter_mut().enumerate().take(150) { + topics.insert(1 + (service + 37) % 119); + } + assert_eq!(memberships.iter().map(BTreeSet::len).sum::(), 600); + + let base_schema = object_schema(false, true); + let mut contracts = Vec::with_capacity(1_800); + for (service, topics) in memberships.iter().enumerate() { + for topic in topics { + for version in 1..=3 { + contracts.push(contract( + &format!("svc{service:03}"), + &format!("topic{topic:03}"), + version, + base_schema.clone(), + )); + } + } + } + assert_eq!(contracts.len(), 1_800); + + let mut relationships = Vec::with_capacity(12_000); + // Put every declared topic into the live topology, then fill the remaining + // relationship budget from the deliberately dense topic000 cohort. + for topic in 1..120 { + let members = memberships + .iter() + .enumerate() + .filter_map(|(service, topics)| topics.contains(&topic).then_some(service)) + .take(2) + .collect::>(); + assert_eq!(members.len(), 2); + relationships.push(json!({ + "topic":format!("topic{topic:03}"), + "producer":format!("svc{:03}", members[0]), + "consumer":format!("svc{:03}", members[1]) + })); + } + 'outer: for producer in 0..150 { + for consumer in 0..150 { + if producer == consumer { + continue; + } + relationships.push(json!({ + "topic":"topic000", + "producer":format!("svc{producer:03}"), + "consumer":format!("svc{consumer:03}") + })); + if relationships.len() == 12_000 { + break 'outer; + } + } + } + assert_eq!(relationships.len(), 12_000); + + let services = (0..300) + .map(|service| (format!("svc{service:03}"), json!([1, 2, 3]))) + .collect::>(); + + let mut candidates = vec![contract("svc000", "topic000", 3, object_schema(true, true))]; + for (service, topics) in memberships.iter().enumerate().take(174).skip(150) { + let topic = *topics.iter().next().expect("service has a topic"); + candidates.push(contract( + &format!("svc{service:03}"), + &format!("topic{topic:03}"), + 3, + base_schema.clone(), + )); + } + assert_eq!(candidates.len(), 25); + + vec![ + ("contracts.json", json!({"contracts": contracts})), + ("topology.json", json!({"relationships": relationships})), + ("fleet.json", json!({"services": services})), + ("candidate.json", json!({"contracts": candidates})), + ] +} + +fn contract(service: &str, topic: &str, version: u32, schema: Value) -> Value { + json!({ + "service":service, + "topic":topic, + "version":version, + "schema":schema + }) +} + +fn object_schema(require_region: bool, include_payload: bool) -> Value { + let mut properties = serde_json::Map::new(); + properties.insert( + "id".to_string(), + json!({"type":"integer","minimum":0,"maximum":1_000_000}), + ); + properties.insert( + "region".to_string(), + json!({"type":"string","enum":["eu","us"]}), + ); + if include_payload { + properties.insert("payload".to_string(), json!({"type":"string"})); + } + let required = if require_region { + json!(["id", "region"]) + } else { + json!(["id"]) + }; + json!({ + "type":"object", + "properties":properties, + "required":required, + "additionalProperties":false + }) +} diff --git a/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/src/lib.rs b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/src/lib.rs new file mode 100644 index 0000000..188c934 --- /dev/null +++ b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/src/lib.rs @@ -0,0 +1,1369 @@ +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; + +use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Number, Value, json}; + +struct StrictValueSeed; + +impl<'de> DeserializeSeed<'de> for StrictValueSeed { + type Value = Value; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(StrictValueVisitor) + } +} + +struct StrictValueVisitor; + +impl<'de> Visitor<'de> for StrictValueVisitor { + type Value = Value; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JSON value without duplicate object members") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("non-finite JSON number")) + } + + fn visit_str(self, value: &str) -> Result { + Ok(Value::String(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(Value::String(value)) + } + + fn visit_none(self) -> Result { + Ok(Value::Null) + } + + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element_seed(StrictValueSeed)? { + values.push(value); + } + Ok(Value::Array(values)) + } + + fn visit_map(self, mut object: A) -> Result + where + A: MapAccess<'de>, + { + let mut values = Map::new(); + while let Some(key) = object.next_key::()? { + if values.contains_key(&key) { + return Err(de::Error::custom(format!( + "duplicate object member `{key}`" + ))); + } + values.insert(key, object.next_value_seed(StrictValueSeed)?); + } + Ok(Value::Object(values)) + } +} + +fn parse_json_strict(text: &str) -> Result { + let mut deserializer = serde_json::Deserializer::from_str(text); + let value = StrictValueSeed.deserialize(&mut deserializer)?; + deserializer.end()?; + Ok(value) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Schema { + kind: Kind, + default: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum Kind { + String { + values: Option>, + }, + Integer { + minimum: Option, + maximum: Option, + }, + Array { + items: Box, + }, + Object { + properties: BTreeMap, + required: BTreeSet, + open: bool, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct ContractKey { + service: String, + topic: String, + version: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct Relationship { + topic: String, + producer: String, + consumer: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct PairKey { + topic: String, + producer_service: String, + producer_version: u32, + consumer_service: String, + consumer_version: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct Violation { + rule: String, + path: String, + witness: Value, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BlockIssue { + pub topic: String, + pub producer_service: String, + pub producer_version: u32, + pub consumer_service: String, + pub consumer_version: u32, + pub rule: String, + pub path: String, + pub witness: Value, +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] +pub struct ReviewIssue { + pub source: String, + pub path: String, + pub message: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum GateStatus { + Allow, + Block, + ReviewRequired, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct GateResult { + pub status: GateStatus, + pub evaluated_pairs: usize, + pub contract_count: usize, + pub candidate_count: usize, + pub issues: Vec, + pub review: Vec, +} + +#[derive(Default)] +struct ParsedInput { + contracts: BTreeMap, + relationships: BTreeSet, + fleet: BTreeMap>, + review: BTreeSet, +} + +pub fn run_files(contracts: &str, topology: &str, fleet: &str, candidate: &str) -> GateResult { + let mut parsed = ParsedInput::default(); + let base_value = read_json(contracts, "contracts.json", &mut parsed.review); + let topology_value = read_json(topology, "topology.json", &mut parsed.review); + let fleet_value = read_json(fleet, "fleet.json", &mut parsed.review); + let candidate_value = read_json(candidate, "candidate.json", &mut parsed.review); + + if let Some(value) = base_value { + parsed.contracts = parse_contract_file(value, "contracts.json", &mut parsed.review); + } + if let Some(value) = topology_value { + parsed.relationships = parse_topology(value, &mut parsed.review); + } + if let Some(value) = fleet_value { + parsed.fleet = parse_fleet(value, &mut parsed.review); + } + let candidates = candidate_value.map_or_else(BTreeMap::new, |value| { + parse_contract_file(value, "candidate.json", &mut parsed.review) + }); + + if !parsed.review.is_empty() { + return GateResult { + status: GateStatus::ReviewRequired, + evaluated_pairs: 0, + contract_count: parsed.contracts.len(), + candidate_count: candidates.len(), + issues: Vec::new(), + review: parsed.review.into_iter().collect(), + }; + } + + let mut reference_review = BTreeSet::new(); + validate_references(&parsed, &candidates, &mut reference_review); + parsed.review.extend(reference_review); + if !parsed.review.is_empty() { + return GateResult { + status: GateStatus::ReviewRequired, + evaluated_pairs: 0, + contract_count: parsed.contracts.len(), + candidate_count: candidates.len(), + issues: Vec::new(), + review: parsed.review.into_iter().collect(), + }; + } + + let base_pairs = evaluate_map(&parsed.contracts, &parsed.relationships, &parsed.fleet); + let evaluated_pairs = base_pairs.len(); + let final_pairs = evaluate_incremental( + base_pairs, + &parsed.contracts, + &candidates, + &parsed.relationships, + &parsed.fleet, + ); + let issues = final_pairs.into_values().flatten().collect::>(); + GateResult { + status: if issues.is_empty() { + GateStatus::Allow + } else { + GateStatus::Block + }, + evaluated_pairs, + contract_count: parsed.contracts.len(), + candidate_count: candidates.len(), + issues, + review: Vec::new(), + } +} + +fn read_json(path: &str, source: &str, review: &mut BTreeSet) -> Option { + match fs::read_to_string(path) { + Ok(text) => match parse_json_strict(&text) { + Ok(value) => Some(value), + Err(error) => { + review.insert(ReviewIssue { + source: source.to_string(), + path: format!("line {}, column {}", error.line(), error.column()), + message: format!("malformed JSON: {error}"), + }); + None + } + }, + Err(error) => { + review.insert(ReviewIssue { + source: source.to_string(), + path: "$".to_string(), + message: format!("cannot read input: {error}"), + }); + None + } + } +} + +fn review_issue(review: &mut BTreeSet, source: &str, path: &str, message: &str) { + review.insert(ReviewIssue { + source: source.to_string(), + path: path.to_string(), + message: message.to_string(), + }); +} + +fn object_at<'a>( + value: &'a Value, + source: &str, + path: &str, + review: &mut BTreeSet, +) -> Option<&'a Map> { + match value.as_object() { + Some(object) => Some(object), + None => { + review_issue(review, source, path, "expected an object"); + None + } + } +} + +fn allowed_keys( + object: &Map, + allowed: &[&str], + source: &str, + path: &str, + review: &mut BTreeSet, +) { + for key in object.keys() { + if !allowed.contains(&key.as_str()) { + review_issue( + review, + source, + &format!("{path}/{}", pointer(key)), + &format!("unsupported field or schema keyword `{key}`"), + ); + } + } +} + +fn parse_contract_file( + value: Value, + source: &str, + review: &mut BTreeSet, +) -> BTreeMap { + let mut out = BTreeMap::new(); + let Some(root) = object_at(&value, source, "$", review) else { + return out; + }; + allowed_keys(root, &["contracts"], source, "$", review); + let Some(entries) = root.get("contracts").and_then(Value::as_array) else { + review_issue(review, source, "/contracts", "expected an array"); + return out; + }; + + for (index, entry) in entries.iter().enumerate() { + let fallback = format!("/contracts/{index}"); + let Some(object) = object_at(entry, source, &fallback, review) else { + continue; + }; + allowed_keys( + object, + &["service", "topic", "version", "schema"], + source, + &fallback, + review, + ); + let service = required_string(object, "service", source, &fallback, review); + let topic = required_string(object, "topic", source, &fallback, review); + let version = required_u32(object, "version", source, &fallback, review); + let (Some(service), Some(topic), Some(version)) = (service, topic, version) else { + continue; + }; + let key = ContractKey { + service, + topic, + version, + }; + let identity = contract_path(&key); + let Some(schema_value) = object.get("schema") else { + review_issue( + review, + source, + &format!("{identity}/schema"), + "missing schema", + ); + continue; + }; + let Some(schema) = + parse_schema(schema_value, source, &format!("{identity}/schema"), review) + else { + continue; + }; + match out.get(&key) { + None => { + out.insert(key, schema); + } + Some(existing) if existing == &schema => {} + Some(_) => review_issue( + review, + source, + &identity, + "conflicting duplicate contract identity", + ), + } + } + out +} + +fn parse_topology(value: Value, review: &mut BTreeSet) -> BTreeSet { + let source = "topology.json"; + let mut out = BTreeSet::new(); + let Some(root) = object_at(&value, source, "$", review) else { + return out; + }; + allowed_keys(root, &["relationships"], source, "$", review); + let Some(entries) = root.get("relationships").and_then(Value::as_array) else { + review_issue(review, source, "/relationships", "expected an array"); + return out; + }; + for (index, entry) in entries.iter().enumerate() { + let path = format!("/relationships/{index}"); + let Some(object) = object_at(entry, source, &path, review) else { + continue; + }; + allowed_keys( + object, + &["topic", "producer", "consumer"], + source, + &path, + review, + ); + let topic = required_string(object, "topic", source, &path, review); + let producer = required_string(object, "producer", source, &path, review); + let consumer = required_string(object, "consumer", source, &path, review); + if let (Some(topic), Some(producer), Some(consumer)) = (topic, producer, consumer) { + out.insert(Relationship { + topic, + producer, + consumer, + }); + } + } + out +} + +fn parse_fleet( + value: Value, + review: &mut BTreeSet, +) -> BTreeMap> { + let source = "fleet.json"; + let mut out = BTreeMap::new(); + let Some(root) = object_at(&value, source, "$", review) else { + return out; + }; + allowed_keys(root, &["services"], source, "$", review); + let Some(services) = root.get("services").and_then(Value::as_object) else { + review_issue(review, source, "/services", "expected an object"); + return out; + }; + for (service, versions) in services { + let path = format!("/services/{}", pointer(service)); + let Some(array) = versions.as_array() else { + review_issue(review, source, &path, "expected an array of versions"); + continue; + }; + let mut version_set = BTreeSet::new(); + for (index, version) in array.iter().enumerate() { + match version.as_u64().and_then(|n| u32::try_from(n).ok()) { + Some(version) if version > 0 => { + version_set.insert(version); + } + _ => review_issue( + review, + source, + &format!("{path}/{index}"), + "version must be a positive 32-bit integer", + ), + } + } + if version_set.is_empty() { + review_issue( + review, + source, + &path, + "service must permit at least one version", + ); + } + out.insert(service.clone(), version_set); + } + out +} + +fn required_string( + object: &Map, + key: &str, + source: &str, + path: &str, + review: &mut BTreeSet, +) -> Option { + match object.get(key).and_then(Value::as_str) { + Some(value) if !value.is_empty() => Some(value.to_string()), + _ => { + review_issue( + review, + source, + &format!("{path}/{}", pointer(key)), + "expected a non-empty string", + ); + None + } + } +} + +fn required_u32( + object: &Map, + key: &str, + source: &str, + path: &str, + review: &mut BTreeSet, +) -> Option { + match object + .get(key) + .and_then(Value::as_u64) + .and_then(|n| u32::try_from(n).ok()) + { + Some(value) if value > 0 => Some(value), + _ => { + review_issue( + review, + source, + &format!("{path}/{}", pointer(key)), + "expected a positive 32-bit integer", + ); + None + } + } +} + +fn parse_schema( + value: &Value, + source: &str, + path: &str, + review: &mut BTreeSet, +) -> Option { + let object = object_at(value, source, path, review)?; + let Some(kind_name) = object.get("type").and_then(Value::as_str) else { + review_issue( + review, + source, + &format!("{path}/type"), + "missing or non-string type", + ); + return None; + }; + let default = object.get("default").cloned(); + let kind = match kind_name { + "string" => { + allowed_keys(object, &["type", "enum", "default"], source, path, review); + let values = match object.get("enum") { + None => None, + Some(value) => { + let Some(array) = value.as_array() else { + review_issue(review, source, &format!("{path}/enum"), "expected an array"); + return None; + }; + let mut values = BTreeSet::new(); + for (index, value) in array.iter().enumerate() { + let Some(value) = value.as_str() else { + review_issue( + review, + source, + &format!("{path}/enum/{index}"), + "enum members must be strings", + ); + continue; + }; + if !values.insert(value.to_string()) { + review_issue( + review, + source, + &format!("{path}/enum/{index}"), + "duplicate enum member", + ); + } + } + if values.is_empty() { + review_issue( + review, + source, + &format!("{path}/enum"), + "enum must contain at least one string", + ); + } + Some(values) + } + }; + Kind::String { values } + } + "integer" => { + allowed_keys( + object, + &["type", "minimum", "maximum", "default"], + source, + path, + review, + ); + let minimum = optional_i64(object, "minimum", source, path, review); + let maximum = optional_i64(object, "maximum", source, path, review); + if let (Some(minimum), Some(maximum)) = (minimum, maximum) + && minimum > maximum + { + review_issue(review, source, path, "minimum must not exceed maximum"); + } + Kind::Integer { minimum, maximum } + } + "array" => { + allowed_keys(object, &["type", "items", "default"], source, path, review); + let Some(items) = object.get("items") else { + review_issue( + review, + source, + &format!("{path}/items"), + "missing items schema", + ); + return None; + }; + Kind::Array { + items: Box::new(parse_schema( + items, + source, + &format!("{path}/items"), + review, + )?), + } + } + "object" => { + allowed_keys( + object, + &[ + "type", + "properties", + "required", + "additionalProperties", + "default", + ], + source, + path, + review, + ); + let Some(properties_value) = object.get("properties") else { + review_issue( + review, + source, + &format!("{path}/properties"), + "missing properties object", + ); + return None; + }; + let Some(properties_object) = properties_value.as_object() else { + review_issue( + review, + source, + &format!("{path}/properties"), + "expected an object", + ); + return None; + }; + let mut properties = BTreeMap::new(); + for (name, property) in properties_object { + if let Some(schema) = parse_schema( + property, + source, + &format!("{path}/properties/{}", pointer(name)), + review, + ) { + properties.insert(name.clone(), schema); + } + } + let mut required = BTreeSet::new(); + match object.get("required") { + None => {} + Some(value) => { + let Some(array) = value.as_array() else { + review_issue( + review, + source, + &format!("{path}/required"), + "expected an array", + ); + return None; + }; + for (index, value) in array.iter().enumerate() { + let Some(name) = value.as_str() else { + review_issue( + review, + source, + &format!("{path}/required/{index}"), + "required member must be a string", + ); + continue; + }; + if !properties.contains_key(name) { + review_issue( + review, + source, + &format!("{path}/required/{index}"), + "required member must name a declared property", + ); + } + if !required.insert(name.to_string()) { + review_issue( + review, + source, + &format!("{path}/required/{index}"), + "duplicate required member", + ); + } + } + } + } + let open = match object.get("additionalProperties") { + None => true, + Some(value) => match value.as_bool() { + Some(value) => value, + None => { + review_issue( + review, + source, + &format!("{path}/additionalProperties"), + "only boolean additionalProperties is supported", + ); + return None; + } + }, + }; + Kind::Object { + properties, + required, + open, + } + } + other => { + review_issue( + review, + source, + &format!("{path}/type"), + &format!("unsupported schema type `{other}`"), + ); + return None; + } + }; + let schema = Schema { kind, default }; + if let Some(default) = &schema.default + && !accepts(&schema, default) + { + review_issue( + review, + source, + &format!("{path}/default"), + "default does not satisfy its schema", + ); + } + Some(schema) +} + +fn optional_i64( + object: &Map, + key: &str, + source: &str, + path: &str, + review: &mut BTreeSet, +) -> Option { + let value = object.get(key)?; + match value.as_i64() { + Some(value) => Some(value), + None => { + review_issue( + review, + source, + &format!("{path}/{}", pointer(key)), + "expected a signed 64-bit integer", + ); + None + } + } +} + +fn validate_references( + parsed: &ParsedInput, + candidates: &BTreeMap, + review: &mut BTreeSet, +) { + let mut merged = parsed.contracts.clone(); + merged.extend(candidates.clone()); + for relationship in &parsed.relationships { + let identity = format!( + "/relationships/topic={}/producer={}/consumer={}", + pointer(&relationship.topic), + pointer(&relationship.producer), + pointer(&relationship.consumer) + ); + for service in [&relationship.producer, &relationship.consumer] { + let Some(versions) = parsed.fleet.get(service) else { + review_issue( + review, + "fleet.json", + &format!("/services/{}", pointer(service)), + "relationship service has no fleet entry", + ); + continue; + }; + for version in versions { + let key = ContractKey { + service: service.clone(), + topic: relationship.topic.clone(), + version: *version, + }; + if !merged.contains_key(&key) { + review_issue( + review, + "topology.json", + &identity, + &format!( + "missing contract for service `{service}`, topic `{}`, version {version}", + relationship.topic + ), + ); + } + } + } + } +} + +fn evaluate_incremental( + mut base: BTreeMap>, + base_contracts: &BTreeMap, + candidates: &BTreeMap, + relationships: &BTreeSet, + fleet: &BTreeMap>, +) -> BTreeMap> { + if candidates.is_empty() { + return base; + } + let mut merged = base_contracts.clone(); + merged.extend(candidates.clone()); + for (pair, issue) in &mut base { + let producer_key = ContractKey { + service: pair.producer_service.clone(), + topic: pair.topic.clone(), + version: pair.producer_version, + }; + let consumer_key = ContractKey { + service: pair.consumer_service.clone(), + topic: pair.topic.clone(), + version: pair.consumer_version, + }; + if candidates.contains_key(&producer_key) || candidates.contains_key(&consumer_key) { + *issue = evaluate_pair(pair, &merged); + } + } + // Candidate-only keys may become active if a base contract was absent, although + // normal reference validation ensures all active keys already existed. + let full_pair_keys = pair_keys(relationships, fleet); + for pair in full_pair_keys { + base.entry(pair.clone()) + .or_insert_with(|| evaluate_pair(&pair, &merged)); + } + base +} + +fn evaluate_map( + contracts: &BTreeMap, + relationships: &BTreeSet, + fleet: &BTreeMap>, +) -> BTreeMap> { + pair_keys(relationships, fleet) + .into_iter() + .map(|pair| { + let issue = evaluate_pair(&pair, contracts); + (pair, issue) + }) + .collect() +} + +fn pair_keys( + relationships: &BTreeSet, + fleet: &BTreeMap>, +) -> BTreeSet { + let mut out = BTreeSet::new(); + for relationship in relationships { + let Some(producer_versions) = fleet.get(&relationship.producer) else { + continue; + }; + let Some(consumer_versions) = fleet.get(&relationship.consumer) else { + continue; + }; + for producer_version in producer_versions { + for consumer_version in consumer_versions { + out.insert(PairKey { + topic: relationship.topic.clone(), + producer_service: relationship.producer.clone(), + producer_version: *producer_version, + consumer_service: relationship.consumer.clone(), + consumer_version: *consumer_version, + }); + } + } + } + out +} + +fn evaluate_pair(pair: &PairKey, contracts: &BTreeMap) -> Option { + let producer = contracts.get(&ContractKey { + service: pair.producer_service.clone(), + topic: pair.topic.clone(), + version: pair.producer_version, + })?; + let consumer = contracts.get(&ContractKey { + service: pair.consumer_service.clone(), + topic: pair.topic.clone(), + version: pair.consumer_version, + })?; + incompatibility(producer, consumer).map(|violation| BlockIssue { + topic: pair.topic.clone(), + producer_service: pair.producer_service.clone(), + producer_version: pair.producer_version, + consumer_service: pair.consumer_service.clone(), + consumer_version: pair.consumer_version, + rule: violation.rule, + path: violation.path, + witness: violation.witness, + }) +} + +pub fn check_schema_pair( + producer: &Value, + consumer: &Value, +) -> Result, Vec> { + let mut review = BTreeSet::new(); + let producer = parse_schema(producer, "producer", "/schema", &mut review); + let consumer = parse_schema(consumer, "consumer", "/schema", &mut review); + if !review.is_empty() { + return Err(review.into_iter().collect()); + } + let violation = incompatibility( + producer.as_ref().expect("parsed producer"), + consumer.as_ref().expect("parsed consumer"), + ); + Ok(violation.map(|violation| (violation.rule, violation.witness))) +} + +fn incompatibility(producer: &Schema, consumer: &Schema) -> Option { + let mut candidates = Vec::new(); + compatibility_candidates(producer, consumer, "", &mut candidates); + candidates.retain(|candidate| { + accepts(producer, &candidate.witness) && !accepts(consumer, &candidate.witness) + }); + candidates.into_iter().min_by(compare_violation) +} + +fn compatibility_candidates( + producer: &Schema, + consumer: &Schema, + path: &str, + out: &mut Vec, +) { + match (&producer.kind, &consumer.kind) { + (Kind::String { values: producer }, Kind::String { values: consumer }) => { + if let Some(consumer) = consumer { + let witness = match producer { + Some(producer) => producer + .iter() + .filter(|value| !consumer.contains(*value)) + .map(|value| Value::String(value.clone())) + .min_by(compare_value), + None => Some(Value::String(smallest_unlisted_string(consumer))), + }; + if let Some(witness) = witness { + out.push(Violation { + rule: "enum-value-rejected".to_string(), + path: path_or_root(path), + witness, + }); + } + } + } + ( + Kind::Integer { + minimum: producer_min, + maximum: producer_max, + }, + Kind::Integer { + minimum: consumer_min, + maximum: consumer_max, + }, + ) => { + if let Some(consumer_min) = consumer_min + && producer_min.is_none_or(|minimum| minimum < *consumer_min) + { + let low = producer_min.unwrap_or(i64::MIN); + let high = producer_max + .unwrap_or(i64::MAX) + .min(consumer_min.saturating_sub(1)); + if low <= high { + out.push(Violation { + rule: "integer-below-minimum".to_string(), + path: path_or_root(path), + witness: json!(representative_integer(low, high)), + }); + } + } + if let Some(consumer_max) = consumer_max + && producer_max.is_none_or(|maximum| maximum > *consumer_max) + { + let low = producer_min + .unwrap_or(i64::MIN) + .max(consumer_max.saturating_add(1)); + let high = producer_max.unwrap_or(i64::MAX); + if low <= high { + out.push(Violation { + rule: "integer-above-maximum".to_string(), + path: path_or_root(path), + witness: json!(representative_integer(low, high)), + }); + } + } + } + (Kind::Array { items: producer }, Kind::Array { items: consumer }) => { + let mut nested = Vec::new(); + compatibility_candidates(producer, consumer, "/0", &mut nested); + for candidate in nested { + out.push(Violation { + rule: candidate.rule, + path: join_path(path, &candidate.path), + witness: Value::Array(vec![candidate.witness]), + }); + } + } + ( + Kind::Object { + properties: producer_properties, + required: producer_required, + open: producer_open, + }, + Kind::Object { + properties: consumer_properties, + required: consumer_required, + open: consumer_open, + }, + ) => { + let base = minimum_object(producer); + for name in consumer_required { + let consumer_property = &consumer_properties[name]; + let producer_must_emit = producer_required.contains(name) + && producer_properties + .get(name) + .is_some_and(|schema| schema.default.is_none()); + if consumer_property.default.is_none() && !producer_must_emit { + let mut witness = base.clone(); + witness.remove(name); + out.push(Violation { + rule: "required-field-missing".to_string(), + path: join_path(path, &format!("/{}", pointer(name))), + witness: Value::Object(witness), + }); + } + } + + for (name, producer_property) in producer_properties { + if let Some(consumer_property) = consumer_properties.get(name) { + let mut nested = Vec::new(); + compatibility_candidates( + producer_property, + consumer_property, + &format!("/{}", pointer(name)), + &mut nested, + ); + for candidate in nested { + let mut witness = base.clone(); + witness.insert(name.clone(), candidate.witness); + out.push(Violation { + rule: candidate.rule, + path: join_path(path, &candidate.path), + witness: Value::Object(witness), + }); + } + } else if !consumer_open { + let mut witness = base.clone(); + witness.insert(name.clone(), minimum_value(producer_property)); + out.push(Violation { + rule: "closed-object-rejects-property".to_string(), + path: join_path(path, &format!("/{}", pointer(name))), + witness: Value::Object(witness), + }); + } + } + + if *producer_open { + if !consumer_open { + let name = smallest_unknown_key(producer_properties, consumer_properties); + let mut witness = base.clone(); + witness.insert(name.clone(), Value::Null); + out.push(Violation { + rule: "closed-object-rejects-property".to_string(), + path: join_path(path, &format!("/{}", pointer(&name))), + witness: Value::Object(witness), + }); + } + for name in consumer_properties.keys() { + if !producer_properties.contains_key(name) { + let mut witness = base.clone(); + witness.insert(name.clone(), Value::Null); + out.push(Violation { + rule: "open-object-property-unconstrained".to_string(), + path: join_path(path, &format!("/{}", pointer(name))), + witness: Value::Object(witness), + }); + } + } + } + } + _ => out.push(Violation { + rule: "type-mismatch".to_string(), + path: path_or_root(path), + witness: minimum_value(producer), + }), + } +} + +fn accepts(schema: &Schema, value: &Value) -> bool { + match &schema.kind { + Kind::String { values } => value + .as_str() + .is_some_and(|value| values.as_ref().is_none_or(|values| values.contains(value))), + Kind::Integer { minimum, maximum } => value.as_i64().is_some_and(|value| { + minimum.is_none_or(|minimum| value >= minimum) + && maximum.is_none_or(|maximum| value <= maximum) + }), + Kind::Array { items } => value + .as_array() + .is_some_and(|values| values.iter().all(|value| accepts(items, value))), + Kind::Object { + properties, + required, + open, + } => value.as_object().is_some_and(|object| { + required.iter().all(|name| { + object.contains_key(name) + || properties + .get(name) + .is_some_and(|schema| schema.default.is_some()) + }) && object.iter().all(|(name, value)| { + properties + .get(name) + .map_or(*open, |schema| accepts(schema, value)) + }) + }), + } +} + +fn minimum_value(schema: &Schema) -> Value { + match &schema.kind { + Kind::String { values } => values.as_ref().map_or_else( + || Value::String(String::new()), + |values| { + values + .iter() + .map(|value| Value::String(value.clone())) + .min_by(compare_value) + .expect("validated non-empty enum") + }, + ), + Kind::Integer { minimum, maximum } => json!(representative_integer( + minimum.unwrap_or(i64::MIN), + maximum.unwrap_or(i64::MAX) + )), + Kind::Array { .. } => Value::Array(Vec::new()), + Kind::Object { .. } => Value::Object(minimum_object(schema)), + } +} + +fn minimum_object(schema: &Schema) -> Map { + let Kind::Object { + properties, + required, + .. + } = &schema.kind + else { + unreachable!() + }; + required + .iter() + .filter_map(|name| { + let property = &properties[name]; + property + .default + .is_none() + .then(|| (name.clone(), minimum_value(property))) + }) + .collect() +} + +fn representative_integer(low: i64, high: i64) -> i64 { + let mut candidates = vec![low, high]; + if low <= 0 && high >= 0 { + candidates.push(0); + } + if low <= -1 && high >= -1 { + candidates.push(-1); + } + if low <= 1 && high >= 1 { + candidates.push(1); + } + candidates + .into_iter() + .min_by(|left, right| compare_value(&json!(left), &json!(right))) + .expect("non-empty integer interval") +} + +fn smallest_unlisted_string(values: &BTreeSet) -> String { + if !values.contains("") { + return String::new(); + } + for length in 1.. { + let candidate = "a".repeat(length); + if !values.contains(&candidate) { + return candidate; + } + } + unreachable!() +} + +fn smallest_unknown_key( + producer: &BTreeMap, + consumer: &BTreeMap, +) -> String { + if !producer.contains_key("") && !consumer.contains_key("") { + return String::new(); + } + for length in 1.. { + let candidate = "a".repeat(length); + if !producer.contains_key(&candidate) && !consumer.contains_key(&candidate) { + return candidate; + } + } + unreachable!() +} + +fn compare_violation(left: &Violation, right: &Violation) -> Ordering { + compare_value(&left.witness, &right.witness) + .then_with(|| left.rule.cmp(&right.rule)) + .then_with(|| left.path.cmp(&right.path)) +} + +fn compare_value(left: &Value, right: &Value) -> Ordering { + value_nodes(left) + .cmp(&value_nodes(right)) + .then_with(|| canonical_json(left).len().cmp(&canonical_json(right).len())) + .then_with(|| canonical_json(left).cmp(&canonical_json(right))) +} + +fn value_nodes(value: &Value) -> usize { + match value { + Value::Array(values) => 1 + values.iter().map(value_nodes).sum::(), + Value::Object(values) => 1 + values.values().map(value_nodes).sum::(), + _ => 1, + } +} + +fn canonical_json(value: &Value) -> String { + match value { + Value::Object(object) => { + let body = object + .iter() + .map(|(key, value)| { + format!( + "{}:{}", + serde_json::to_string(key).expect("string serialization"), + canonical_json(value) + ) + }) + .collect::>() + .join(","); + format!("{{{body}}}") + } + Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(canonical_json) + .collect::>() + .join(",") + ), + _ => serde_json::to_string(value).expect("value serialization"), + } +} + +fn path_or_root(path: &str) -> String { + if path.is_empty() { + "/".to_string() + } else { + path.to_string() + } +} + +fn join_path(prefix: &str, suffix: &str) -> String { + if prefix.is_empty() { + path_or_root(suffix) + } else if suffix == "/" { + prefix.to_string() + } else { + format!("{prefix}{suffix}") + } +} + +fn pointer(value: &str) -> String { + value.replace('~', "~0").replace('/', "~1") +} + +fn contract_path(key: &ContractKey) -> String { + format!( + "/contracts/service={}/topic={}/version={}", + pointer(&key.service), + pointer(&key.topic), + key.version + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unsupported_keyword_has_exact_semantic_location() { + let mut review = BTreeSet::new(); + let input = json!({"contracts":[{ + "service":"a", "topic":"t", "version":1, + "schema":{"type":"string", "pattern":"x"} + }]}); + parse_contract_file(input, "contracts.json", &mut review); + let issue = review.into_iter().next().expect("review issue"); + assert_eq!( + issue.path, + "/contracts/service=a/topic=t/version=1/schema/pattern" + ); + } + + #[test] + fn malformed_default_needs_review() { + let result = check_schema_pair( + &json!({"type":"integer", "minimum":1, "default":0}), + &json!({"type":"integer"}), + ); + assert!(result.is_err()); + } + + #[test] + fn witness_is_accepted_only_by_producer() { + let producer = json!({"type":"object","properties":{"n":{"type":"integer","minimum":0,"maximum":10}},"required":["n"],"additionalProperties":false}); + let consumer = json!({"type":"object","properties":{"n":{"type":"integer","minimum":1,"maximum":10}},"required":["n"],"additionalProperties":false}); + let (rule, witness) = check_schema_pair(&producer, &consumer) + .expect("valid") + .expect("block"); + assert_eq!(rule, "integer-below-minimum"); + let mut review = BTreeSet::new(); + let producer = parse_schema(&producer, "p", "/", &mut review).unwrap(); + let consumer = parse_schema(&consumer, "c", "/", &mut review).unwrap(); + assert!(accepts(&producer, &witness)); + assert!(!accepts(&consumer, &witness)); + } +} diff --git a/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/src/main.rs b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/src/main.rs new file mode 100644 index 0000000..0b6feac --- /dev/null +++ b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/src/main.rs @@ -0,0 +1,25 @@ +use std::env; +use std::process::ExitCode; + +use mixed_version_contract_gate::{GateStatus, run_files}; + +fn main() -> ExitCode { + let args: Vec = env::args().skip(1).collect(); + if args.len() != 4 { + eprintln!( + "usage: contract-gate " + ); + return ExitCode::from(2); + } + + let result = run_files(&args[0], &args[1], &args[2], &args[3]); + println!( + "{}", + serde_json::to_string_pretty(&result).expect("result is serializable") + ); + match result.status { + GateStatus::Allow => ExitCode::SUCCESS, + GateStatus::Block => ExitCode::from(1), + GateStatus::ReviewRequired => ExitCode::from(2), + } +} diff --git a/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/tests/semantic.rs b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/tests/semantic.rs new file mode 100644 index 0000000..73191b2 --- /dev/null +++ b/developer-simulation/runs/2026-08-04--mixed-version-contract-gate/tests/semantic.rs @@ -0,0 +1,273 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use mixed_version_contract_gate::{GateStatus, check_schema_pair, run_files}; +use serde_json::{Value, json}; + +#[test] +fn semantic_fixture_has_at_least_sixty_cases_and_matches() { + let fixture: Value = serde_json::from_str(include_str!("../fixtures/semantic_cases.json")) + .expect("valid fixture JSON"); + let schemas = fixture["schemas"].as_object().expect("schema catalog"); + let cases = fixture["cases"].as_array().expect("case array"); + assert!(cases.len() >= 60, "semantic suite must retain >=60 cases"); + for case in cases { + let name = case["name"].as_str().expect("case name"); + let producer = &schemas[case["producer"].as_str().expect("producer ref")]; + let consumer = &schemas[case["consumer"].as_str().expect("consumer ref")]; + let outcome = check_schema_pair(producer, consumer) + .unwrap_or_else(|review| panic!("{name}: unexpected review: {review:?}")); + match case["expected"].as_str().expect("expected status") { + "allow" => assert!(outcome.is_none(), "{name}: expected allow, got {outcome:?}"), + "block" => { + let (rule, _) = outcome.unwrap_or_else(|| panic!("{name}: expected block")); + assert_eq!( + rule, + case["rule"].as_str().expect("expected rule"), + "{name}" + ); + } + other => panic!("{name}: unknown expected status {other}"), + } + } +} + +#[test] +fn incremental_candidate_exactly_matches_full_evaluation() { + let dir = temp_case_dir("parity"); + let (base, topology, fleet, candidate, merged) = mixed_version_inputs(); + write_json(&dir, "base.json", &base); + write_json(&dir, "topology.json", &topology); + write_json(&dir, "fleet.json", &fleet); + write_json(&dir, "candidate.json", &candidate); + write_json(&dir, "merged.json", &merged); + write_json(&dir, "empty.json", &json!({"contracts":[]})); + + let incremental = run_case(&dir, "base.json", "candidate.json"); + let full = run_case(&dir, "merged.json", "empty.json"); + assert_eq!(incremental.status, full.status); + assert_eq!(incremental.evaluated_pairs, full.evaluated_pairs); + assert_eq!(incremental.issues, full.issues); + assert_eq!(incremental.review, full.review); + assert_eq!(incremental.evaluated_pairs, 9); + assert_eq!(incremental.issues.len(), 3); +} + +#[test] +fn shuffled_and_identical_duplicate_inputs_are_stable() { + let dir = temp_case_dir("shuffle"); + let (base, topology, fleet, candidate, _) = mixed_version_inputs(); + write_json(&dir, "base.json", &base); + write_json(&dir, "topology.json", &topology); + write_json(&dir, "fleet.json", &fleet); + write_json(&dir, "candidate.json", &candidate); + let expected = run_case(&dir, "base.json", "candidate.json"); + + let mut shuffled_contracts = base["contracts"].as_array().unwrap().clone(); + shuffled_contracts.reverse(); + shuffled_contracts.push(shuffled_contracts[0].clone()); + let mut duplicate_relationships = topology["relationships"].as_array().unwrap().clone(); + duplicate_relationships.push(duplicate_relationships[0].clone()); + let mut duplicate_candidates = candidate["contracts"].as_array().unwrap().clone(); + duplicate_candidates.push(duplicate_candidates[0].clone()); + let shuffled_fleet = json!({"services":{"consumer":[3,2,1,3],"producer":[2,1,3,2]}}); + + write_json( + &dir, + "base-shuffled.json", + &json!({"contracts":shuffled_contracts}), + ); + write_json( + &dir, + "topology.json", + &json!({"relationships":duplicate_relationships}), + ); + write_json(&dir, "fleet.json", &shuffled_fleet); + write_json( + &dir, + "candidate-shuffled.json", + &json!({"contracts":duplicate_candidates}), + ); + let actual = run_case(&dir, "base-shuffled.json", "candidate-shuffled.json"); + assert_eq!(expected, actual); +} + +#[test] +fn unsupported_and_malformed_contracts_never_allow() { + let dir = temp_case_dir("review"); + let base = json!({"contracts":[ + {"service":"producer","topic":"orders","version":1,"schema":{"type":"string","pattern":"^[a-z]+$"}}, + {"service":"consumer","topic":"orders","version":1,"schema":{"type":"integer","minimum":10,"maximum":1}} + ]}); + write_json(&dir, "base.json", &base); + write_json( + &dir, + "topology.json", + &json!({"relationships":[{"topic":"orders","producer":"producer","consumer":"consumer"}]}), + ); + write_json( + &dir, + "fleet.json", + &json!({"services":{"producer":[1],"consumer":[1]}}), + ); + write_json(&dir, "candidate.json", &json!({"contracts":[]})); + let result = run_case(&dir, "base.json", "candidate.json"); + assert_eq!(result.status, GateStatus::ReviewRequired); + assert!( + result + .review + .iter() + .any(|issue| issue.path.ends_with("/pattern")) + ); + assert!( + result + .review + .iter() + .any(|issue| issue.message.contains("minimum must not exceed")) + ); +} + +#[test] +fn duplicate_json_members_at_every_input_layer_require_review() { + let cases = [ + ("root", "base.json", r#"{"contracts":[],"contracts":[]}"#), + ( + "contract", + "base.json", + r#"{"contracts":[{"service":"producer","service":"other","topic":"orders","version":1,"schema":{"type":"string"}}]}"#, + ), + ( + "schema", + "base.json", + r#"{"contracts":[{"service":"producer","topic":"orders","version":1,"schema":{"type":"string","type":"integer"}}]}"#, + ), + ( + "topology", + "topology.json", + r#"{"relationships":[{"topic":"orders","producer":"producer","producer":"other","consumer":"consumer"}]}"#, + ), + ( + "fleet", + "fleet.json", + r#"{"services":{"producer":[1],"producer":[1],"consumer":[1]}}"#, + ), + ( + "candidate", + "candidate.json", + r#"{"contracts":[{"service":"consumer","topic":"orders","version":1,"schema":{"type":"string","type":"integer"}}]}"#, + ), + ]; + + for (label, filename, raw) in cases { + let dir = temp_case_dir(label); + write_json( + &dir, + "base.json", + &json!({"contracts":[ + contract("producer", 1, json!({"type":"string"})), + contract("consumer", 1, json!({"type":"string"})) + ]}), + ); + write_json( + &dir, + "topology.json", + &json!({"relationships":[{ + "topic":"orders","producer":"producer","consumer":"consumer" + }]}), + ); + write_json( + &dir, + "fleet.json", + &json!({"services":{"producer":[1],"consumer":[1]}}), + ); + write_json(&dir, "candidate.json", &json!({"contracts":[]})); + fs::write(dir.join(filename), raw).expect("write raw duplicate-key JSON"); + + let result = run_case(&dir, "base.json", "candidate.json"); + assert_eq!(result.status, GateStatus::ReviewRequired, "{label}"); + assert!( + result + .review + .iter() + .any(|issue| issue.message.contains("duplicate object member")), + "{label}: {:?}", + result.review + ); + } +} + +fn mixed_version_inputs() -> (Value, Value, Value, Value, Value) { + let base_schema = schema(false); + let candidate_schema = schema(true); + let mut contracts = Vec::new(); + for version in 1..=3 { + contracts.push(contract("producer", version, base_schema.clone())); + contracts.push(contract("consumer", version, base_schema.clone())); + } + let base = json!({"contracts":contracts}); + let topology = json!({"relationships":[{ + "topic":"orders","producer":"producer","consumer":"consumer" + }]}); + let fleet = json!({"services":{"producer":[1,2,3],"consumer":[1,2,3]}}); + let replacement = contract("consumer", 3, candidate_schema); + let candidate = json!({"contracts":[replacement.clone()]}); + let mut merged_contracts = base["contracts"].as_array().unwrap().clone(); + let index = merged_contracts + .iter() + .position(|entry| entry["service"] == "consumer" && entry["version"] == 3) + .unwrap(); + merged_contracts[index] = replacement; + let merged = json!({"contracts":merged_contracts}); + (base, topology, fleet, candidate, merged) +} + +fn schema(require_region: bool) -> Value { + json!({ + "type":"object", + "properties":{ + "id":{"type":"integer","minimum":0,"maximum":100}, + "region":{"type":"string","enum":["eu","us"]} + }, + "required": if require_region { json!(["id","region"]) } else { json!(["id"]) }, + "additionalProperties":false + }) +} + +fn contract(service: &str, version: u32, schema: Value) -> Value { + json!({"service":service,"topic":"orders","version":version,"schema":schema}) +} + +fn temp_case_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "contract-gate-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).expect("create temp case"); + path +} + +fn write_json(dir: &Path, name: &str, value: &Value) { + fs::write( + dir.join(name), + serde_json::to_vec(value).expect("serialize test input"), + ) + .expect("write test input"); +} + +fn run_case( + dir: &Path, + contracts: &str, + candidate: &str, +) -> mixed_version_contract_gate::GateResult { + run_files( + &dir.join(contracts).to_string_lossy(), + &dir.join("topology.json").to_string_lossy(), + &dir.join("fleet.json").to_string_lossy(), + &dir.join(candidate).to_string_lossy(), + ) +} diff --git a/developer-simulation/runs/2026-08-04--ocr-redaction-remap/Cargo.toml b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/Cargo.toml new file mode 100644 index 0000000..e9fe8e8 --- /dev/null +++ b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ocr-redaction-remap" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +unicode-normalization = "0.1" +unicode-segmentation = "1" diff --git a/developer-simulation/runs/2026-08-04--ocr-redaction-remap/README.md b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/README.md new file mode 100644 index 0000000..64cb116 --- /dev/null +++ b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/README.md @@ -0,0 +1,69 @@ +# OCR redaction remap trial + +This is a standalone Rust CLI for remapping human-reviewed UTF-8 spans from old OCR text to revised grapheme rectangles. It processes one JSON page at a time, emits no OCR text, validates all geometry before marking a page publishable, and checkpoints paired rectangle/audit streams for deterministic resume. + +It intentionally does **not** use a BogKit crate. Fold's durable incremental views solve a different problem from independent, authoritative page transformations; ESE and ANNy have no role in this mapping. + +## Input and output + +Each input JSONL line has: + +- `page_id`, `old_text`, and `revised_text` +- `glyphs`: revised graphemes encoded as `[start_byte,end_byte,line,x,y,width,height]`; line-break graphemes have no rectangle +- `spans`: reviewed old-text `{start,end,reason}` byte ranges + +Output JSONL contains only page IDs, status, rectangles, and a non-sensitive error code. The separate audit JSONL contains counts, allow-listed identifier-shaped reason codes, and decisions (`exact`, `removed`, `fallback_token`, or `fallback_line`), never source text or matched text. + +## Run the verified fixture demo + +Run from the BogKit repository root: + +```sh +developer-simulation/runs/2026-08-04--ocr-redaction-remap/run_demo.sh +``` + +The script generates 240 systematic fixtures plus four hand cases; verifies +exact rectangle identity and sentinel absence; checks reversed/duplicated spans +for byte identity; and runs two interruption/resume comparisons. Generated +files use a temporary directory under `/private/tmp` and are removed on exit; +build output uses `/private/tmp/ocr-redaction-remap-target`. + +## Individual commands + +```sh +export CARGO_TARGET_DIR=/private/tmp/ocr-redaction-remap-target +DEMO_DIR="$(mktemp -d /private/tmp/ocr-redaction-remap-demo.XXXXXX)" +cargo run --offline --locked --manifest-path developer-simulation/Cargo.toml \ + -p ocr-redaction-remap -- generate --dir "$DEMO_DIR/fixtures" --pages 240 +cargo run --offline --locked --manifest-path developer-simulation/Cargo.toml \ + -p ocr-redaction-remap -- remap \ + --input "$DEMO_DIR/fixtures/pages.jsonl" \ + --output "$DEMO_DIR/output.jsonl" \ + --audit "$DEMO_DIR/audit.jsonl" +cargo run --offline --locked --manifest-path developer-simulation/Cargo.toml \ + -p ocr-redaction-remap -- check \ + --input "$DEMO_DIR/fixtures/pages.jsonl" \ + --output "$DEMO_DIR/output.jsonl" \ + --audit "$DEMO_DIR/audit.jsonl" \ + --expected "$DEMO_DIR/fixtures/expected.jsonl" \ + --sentinels "$DEMO_DIR/fixtures/sentinels.json" +``` + +Use `--stop-after N` to simulate interruption and `--resume` to continue from +the last paired checkpoint. A completed run writes `.complete`; it binds +the exact input, output, and audit paths, lengths, and SHA-256 values. Consumers +should verify through `--resume` and reject any page whose status is `blocked`. +This is controlled process-resume evidence, not a power-loss durability +guarantee. + +Generate the exact acceptance workload with: + +```sh +cargo run --release --offline --locked \ + --manifest-path developer-simulation/Cargo.toml \ + -p ocr-redaction-remap -- generate-workload \ + --output "$DEMO_DIR/workload.jsonl" \ + --pages 5000 --scalars-per-page 4000 --spans-per-page 30 +``` + +The mapper caps non-trivial edit alignment at 256 edits per page. Pages over that limit are blocked rather than guessed. diff --git a/developer-simulation/runs/2026-08-04--ocr-redaction-remap/TRIAL_REPORT.md b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/TRIAL_REPORT.md new file mode 100644 index 0000000..bd4af17 --- /dev/null +++ b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/TRIAL_REPORT.md @@ -0,0 +1,255 @@ +# Trial notes — OCR redaction remapping + +## Skeptical review and coordinator correction + +The independent reviewer reproduced the fixture demo and no-fit decision, then +found three serious prototype defects before archival. A unique literal match in +the revised text could select the wrong repeated occurrence after an OCR change, +leaving the reviewed occurrence uncovered. A completed-run marker trusted +changed input or truncated output. A stale checkpoint could also extend a newly +truncated partial file with NUL bytes and report success. + +The coordinator fixed all three classes. Literal candidates are now checked +against the source-position edit mapping and disagreements are covered +conservatively. Completion markers bind the exact input, output, and audit paths, +lengths, and SHA-256 values. Fresh runs invalidate stale partial/checkpoint state; +resume rejects short or changed partial prefixes instead of extending them. +Seven corrected unit tests cover the reviewer's exact repeated-occurrence, +changed-input, truncated-output, changed-audit, stale-checkpoint, and short- +partial reproductions. The 244-page demo, formatting, strict lint, and both +controlled resumes still pass. + +The corrected nested-workspace 5,000-page, 20-million-ASCII-scalar, +150,000-span workload completed in 38.82 seconds with 5,947,392 bytes maximum +RSS, produced 5,000 +output and audit lines, and matched a second full output byte-for-byte. This is +controlled process-resume and workload-specific evidence, not a general Unicode, +confidentiality, durability, crash, or power-loss guarantee. No BogKit defect was +demonstrated. + +The remainder preserves the blind developer's trail. This reviewed correction +controls where an initial claim differs. + +Run date: 2026-08-04 (America/New_York) + +Persona: public-records processing engineer; production Python experience; Rust beginner. + +## Outcome + +Built a standalone Rust CLI under `trial/` that streams one page-delimited JSON record at a time, remaps reviewed old-text spans to revised grapheme rectangles, emits a content-free audit, blocks pages with invalid spans or geometry, and resumes paired output/audit files after controlled process interruption. The trial uses no BogKit crate because none fits this independent deterministic transformation. + +Observed acceptance evidence: + +- 240 systematic pages plus four hand pages: 243 exact pages, one explicit conservative repeated-token page, zero blocked pages, zero rectangle mismatches, and zero sentinel leaks in rectangle/audit output. +- Old/revised fixtures cover combining marks, multibyte text, compatibility ligatures, soft hyphens, line-break dehyphenation, whitespace normalization, an OCR character substitution, repeated phrases with and without distinguishing context, overlapping spans, duplicates, and deletion. +- Reversing span order and adding duplicates produced byte-identical rectangle and audit files. +- Two controlled interruptions after pages 73 and 191 resumed to byte-identical uninterrupted outputs. Broader crash and split-finalization claims were not retained as reviewed evidence. +- Corrupt offsets, an offset inside a multibyte character, missing geometry, and contradictory geometry each returned exit 2 with a deterministic blocked page and zero rectangles. Two runs had identical SHA-256 values. Invalid raw UTF-8 returned exit 2 with only `error code=invalid_utf8 line=1`. +- The final corrected nested-workspace workload of 5,000 pages, 20,000,000 + ASCII scalar values, and 150,000 reviewed spans completed in 38.82 seconds. + macOS `/usr/bin/time -l` reported 5,947,392 bytes maximum resident set size, below + the 64 MiB limit. It emitted 5,000 output lines, 5,000 audit lines, 600,000 + exact rectangles, zero blocked pages, and zero conservative pages. A second + completed workload output was byte-identical. +- `cargo fmt -- --check`, seven unit tests, Clippy with warnings denied, a debug + build, a release build, and the end-to-end demo all passed. + +## Discovery order and friction + +I behaved as if I had no prior BogKit knowledge and did not inspect any prior simulation material. + +1. Ran `pwd`, `git status --short --branch`, `ls -la`, then read public `README.md`. The checkout was detached and clean. The README said the smallest start was `./scripts/new-project.sh`, described Fold, ESE, and ANNy, and listed examples in the order starter, timeseries, chat, search. +2. Listed public example files and read their public source in README order: `examples/starter/src/main.rs`, `examples/timeseries/src/main.rs`, `examples/chat/src/main.rs`, then `examples/search/src/main.rs`. +3. Tried to freeze the advertised smallest runnable BogKit baseline with: + + ```sh + CARGO_TARGET_DIR=/private/tmp/bogkit-sim-2026-08-04-b/trial/.baseline-target cargo run -p starter + ``` + + It failed while building ESE. ESE attempted to download `model.safetensors`, then panicked after DNS resolution failed. This was before any trial design choice. +4. Only after that failure, read root `Cargo.toml`, `examples/starter/Cargo.toml`, and `scripts/new-project.sh`. The starter manifest declares `anny`, `ese`, and `fold` even though its source uses only Fold. The scaffold script also adds all three and the README says they may not all be used. That explains why the unrelated model download blocks the advertised starter offline. +5. Froze the existing-system baseline independently with `python3 trial/baseline_clamp.py`. It returned: + + ```json + {"baseline": "stale_offset_clamp", "partial_exposure_reproduced": true} + ``` + + The reproducer asserts the exposure but prints no sensitive fixture text. + +## Fit decision + +No BogKit component was used. + +- Fold maintains durable views as inserts and retractions arrive. Here every page is an authoritative, independent old/revised pair and the required result is a deterministic append-only transformation. Fold would introduce durable mutable state and a second recovery model without improving alignment, geometry validation, or ambiguity handling. +- ESE generates semantic embeddings; no semantic retrieval or similarity model is required. +- ANNy indexes nearest neighbors; pages and reviewed spans must never be matched approximately across records. +- The public surface inspected did not expose a Unicode sequence-alignment or glyph-geometry redaction primitive. + +This is a poor product fit, not a demonstrated defect in Fold, ESE, or ANNy correctness. The starter's unrelated ESE build coupling is a separate observed setup defect. + +## Prototype design + +- Input spans remain authoritative UTF-8 byte ranges. Out-of-range, empty/reversed, or non-boundary offsets block the page. +- Unicode graphemes are preserved for rectangles. Matching tokens use NFKC, remove soft hyphens, collapse whitespace, expand compatibility ligatures, and remove line-break hyphenation only between alphanumeric characters. +- A unique normalized occurrence maps exactly. Repeated occurrences use up to 32 tokens of surrounding context; a unique best context remains exact, while a tie redacts every smallest matching token occurrence. +- When normalized text differs, a deterministic Myers edit script maps replacement blocks. Unique 4–8-token flanks make the local mapping exact. Without unique flanks, the mapper uses the implicated line as an explicit conservative fallback; if no safe line can be identified, it blocks publication. +- Non-trivial edit distance is capped at 256 edits per page. Larger rewrites return `edit_distance_limit`; they are never guessed. +- Every non-line-break revised grapheme must have exactly one positive-size rectangle. Missing, duplicate, extra, or contradictory geometry blocks the page and produces no rectangles. +- Reviewed spans are sorted and deduplicated before processing. Rectangles and reason codes are sorted and deduplicated. No offsets, source text, revised text, or matched token is present in the audit. +- Each checkpoint records completed input lines, paired lengths, rolling input/ + output/audit prefix hashes, and blocked-page count. Resume rejects short or + changed partial prefixes, then truncates only uncheckpointed suffixes. A + completion marker binds the exact paths, lengths, and SHA-256 values of the + input and both final streams. Consumers must verify it and reject `blocked` + pages. Directory-sync and power-loss durability remain outside the evidence. + +## Exact verification commands and observed results + +All commands ran from `/private/tmp/bogkit-sim-2026-08-04-b`. + +### Formatting, tests, and lint + +```sh +cargo fmt --manifest-path trial/Cargo.toml -- --check +CARGO_TARGET_DIR=/private/tmp/bogkit-sim-2026-08-04-b/trial/.target cargo test --offline --manifest-path trial/Cargo.toml +CARGO_TARGET_DIR=/private/tmp/bogkit-sim-2026-08-04-b/trial/.target cargo clippy --offline --all-targets --manifest-path trial/Cargo.toml -- -D warnings +``` + +Observed: formatting passed; four tests passed; Clippy finished with no warnings. + +### Complete demo + +```sh +trial/run_demo.sh +``` + +Observed final lines: + +```text +checked pages=244 exact=243 conservative=1 sentinel_leaks=0 rectangle_mismatches=0 +demo passed: exact rectangles, content-free output, duplicate identity, and two resumes +``` + +The script also compared normal versus reversed/duplicated spans and both resumed runs with `cmp`; every comparison returned 0. + +### Malformed input + +Representative command (repeated for all four structured malformed files): + +```sh +trial/.target/debug/ocr-redaction-remap remap \ + --input trial/generated/malformed/corrupt_offset.jsonl \ + --output trial/generated/malformed/corrupt-output.jsonl \ + --audit trial/generated/malformed/corrupt-audit.jsonl +``` + +Observed structured errors and exit status: + +| Case | Page error code | Exit | Rectangles | +|---|---|---:|---:| +| corrupt offset | `corrupt_offset` | 2 | 0 | +| byte inside multibyte character | `invalid_utf8_boundary` | 2 | 0 | +| missing rectangle | `missing_geometry` | 2 | 0 | +| duplicate/contradictory rectangle | `contradictory_geometry` | 2 | 0 | + +Two-run SHA-256 values were unchanged: + +```text +ad4fc132cfbe8b5db81cd98e49379be6d48078e8456bf9f16b81645ed05812ce corrupt-output.jsonl +a02c3c9790d1220b2f9320db89dd3312a333e187430e9796489bd2870f7e0165 boundary-output.jsonl +4d0957a82e9b4db1cb620efb438fdbfd98702a5e76cdd541b716f6045c4c14ac missing-output.jsonl +a87492b1c5151e1b70ebada56778f302ee3b8f79a38331a834cd3064b952cbaa contradictory-output.jsonl +``` + +Invalid raw UTF-8 was run twice. Both runs returned exit 2 and exactly: + +```text +error code=invalid_utf8 line=1 +``` + +### Full workload + +```sh +trial/.target/release/ocr-redaction-remap generate-workload \ + --output trial/.workload/pages.jsonl \ + --pages 5000 --scalars-per-page 4000 --spans-per-page 30 + +/usr/bin/time -l -o trial/.workload/time.txt \ + trial/.target/release/ocr-redaction-remap remap \ + --input trial/.workload/pages.jsonl \ + --output trial/.workload/timed-output.jsonl \ + --audit trial/.workload/timed-audit.jsonl +``` + +Observed: + +```text +generated workload_pages=5000 scalars=20000000 spans=150000 +38.82 real 13.40 user 2.13 sys +5947392 maximum resident set size +5000 output lines; 5000 audit lines +0 blocked; 0 conservative; 600000 rectangles +``` + +`cmp` confirmed the timed output and audit were byte-identical to the earlier uninterrupted workload run. + +## Findings + +### 1. Stale-offset clamp can expose sensitive text + +- Category: correctness defect in the existing Python baseline, not BogKit. +- Severity: critical. +- Confidence: high; deterministic assertion-backed reproducer. +- Reproduction: `python3 trial/baseline_clamp.py`. +- Smallest plausible improvement: stop clamping revised offsets; require a validated old-to-revised mapping and block publication on unresolved mapping or geometry. + +### 2. Advertised starter is coupled to an unrelated model download + +- Category: API friction and documentation gap in the current BogKit public starter. +- Severity: medium for first-run/offline use. +- Confidence: high for this detached current-main checkout; observed command and manifests explain the failure. +- Reproduction: the `cargo run -p starter` command above in a fresh target directory. +- Smallest plausible improvement: remove unused ESE/ANNy dependencies from `examples/starter/Cargo.toml`; make the scaffold add components on demand, or clearly disclose the ESE model download. + +### 3. BogKit is a poor fit for this mapper + +- Category: poor product fit; public-surface missing capability. +- Severity: informational. +- Confidence: high for Fold/ESE/ANNy roles described by the public README and examples; medium for the absence of any unpublished/internal primitive because this trial intentionally began and stayed on the public surface. +- Reproduction: compare the page-local transformation requirements with the starter/timeseries/chat/search examples. +- Smallest plausible improvement: none recommended for this task. A standalone streaming transformer is smaller and has a clearer recovery boundary. + +### 4. Broad rewrites are intentionally blocked + +- Category: prototype missing capability, not BogKit. +- Severity: moderate availability limitation; safe for confidentiality. +- Confidence: high; explicit `MAX_EDIT_DISTANCE = 256` behavior. +- Reproduction: provide a page whose normalized old/revised edit distance exceeds 256. +- Smallest plausible improvement: add a linear-space anchor partitioner with a separately tested memory bound before increasing the cap. + +### 5. Measured performance meets the stated bound + +- Category: performance result, no observed performance problem. +- Severity: informational. +- Confidence: high for the exact generated workload executed on this machine. +- Reproduction: full-workload commands above. +- Smallest plausible improvement: none required for the stated 64 MiB target; production inputs should still be profiled because geometry number formatting and page size can differ. + +## Consequential-choice audit + +| Choice | Decision | Evidence | Consequence / unresolved uncertainty | +|---|---|---|---| +| Use BogKit | No | Public components maintain views, embeddings, or nearest-neighbor indexes; none performs authoritative Unicode remapping. | Avoids unrelated state and model dependencies. Does not assess unpublished APIs. | +| Offset model | Validate UTF-8 bytes, map normalized graphemes | Invalid boundaries blocked deterministically; Unicode fixtures exact. | A reviewed span that normalizes to no token blocks rather than guessing. | +| Repeated sensitive text | Redact every tied smallest token candidate | Hand ambiguity case redacted both occurrences and nothing between them. | Can reduce utility but does not select an arbitrary occurrence. | +| Changed text | Bounded deterministic diff plus unique flanks | OCR substitution and deletion fixtures mapped exactly. | More than 256 edits blocks the page. Production normalization rules may need additional explicit transforms. | +| Geometry | Require complete one-to-one revised grapheme geometry | Missing and contradictory fixtures blocked with zero rectangles. | No bidirectional or arbitrary layout handling, consistent with non-goals. | +| Duplicate spans | Sort/deduplicate before processing | Reversed and duplicated input was byte-identical. | Same offsets with different valid reason codes remain two audit decisions but one rectangle union. | +| Recovery | Paired lengths and prefix hashes + SHA-256-bound completion marker | Two controlled resumes and the reviewer regressions pass. | This is process-resume evidence only; no exhaustive crash or power-loss injection was performed. | +| Diagnostics | Static error codes and counts only | Leak checker found zero fixture sentinels; invalid UTF-8 error was content-free. | Page IDs and reason codes are required to be non-sensitive identifier-shaped metadata. | + +## Scope and cleanup + +No BogKit/core/example/root file was modified. There was no commit, GitHub write, automation write, PDF/OCR/database/network dependency in the prototype, or archive into another checkout. Network was used only once to resolve the trial's four small Rust library dependencies after the sandboxed index lookup failed; subsequent verification ran offline from `trial/Cargo.lock`. + +Generated build targets, demo outputs, and the 493 MiB workload were removed after recording the measurements. The retained handoff is source, lockfile, README, runnable demo/generators/checker, baseline reproducer, and these notes. diff --git a/developer-simulation/runs/2026-08-04--ocr-redaction-remap/baseline_clamp.py b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/baseline_clamp.py new file mode 100644 index 0000000..716454c --- /dev/null +++ b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/baseline_clamp.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Content-free reproducer for stale-offset clamping. + +The assertions model the existing policy; stdout intentionally contains no OCR text. +""" + +import json + + +old_text = "prefix extra account-12345 suffix" +revised_text = "prefix account 12345 suffix" +start = old_text.index("account-12345") +end = start + len("account-12345") + +clamped_start = min(start, len(revised_text)) +clamped_end = min(end, len(revised_text)) +covered = revised_text[clamped_start:clamped_end] + +assert "account 12345" not in covered, "fixture must reproduce partial exposure" +assert revised_text.index("account 12345") < clamped_start, "fixture must expose a sensitive prefix" + +print(json.dumps({"baseline": "stale_offset_clamp", "partial_exposure_reproduced": True})) diff --git a/developer-simulation/runs/2026-08-04--ocr-redaction-remap/run_demo.sh b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/run_demo.sh new file mode 100755 index 0000000..ed0f603 --- /dev/null +++ b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/run_demo.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +repo_root="$(cd "$here/../../.." && pwd)" +demo="$(mktemp -d /private/tmp/ocr-redaction-remap-demo.XXXXXX)" +target="/private/tmp/ocr-redaction-remap-target" +binary="$target/debug/ocr-redaction-remap" +trap 'rm -rf "$demo"' EXIT + +CARGO_TARGET_DIR="$target" cargo build --offline --locked \ + --manifest-path "$repo_root/developer-simulation/Cargo.toml" \ + -p ocr-redaction-remap +"$binary" generate --dir "$demo/fixtures" --pages 240 +"$binary" remap --input "$demo/fixtures/pages.jsonl" --output "$demo/output.jsonl" --audit "$demo/audit.jsonl" +"$binary" check --input "$demo/fixtures/pages.jsonl" --output "$demo/output.jsonl" --audit "$demo/audit.jsonl" --expected "$demo/fixtures/expected.jsonl" --sentinels "$demo/fixtures/sentinels.json" + +"$binary" remap --input "$demo/fixtures/pages_shuffled_duplicated.jsonl" --output "$demo/variant-output.jsonl" --audit "$demo/variant-audit.jsonl" +cmp "$demo/output.jsonl" "$demo/variant-output.jsonl" +cmp "$demo/audit.jsonl" "$demo/variant-audit.jsonl" + +set +e +"$binary" remap --input "$demo/fixtures/pages.jsonl" --output "$demo/resume-a-output.jsonl" --audit "$demo/resume-a-audit.jsonl" --stop-after 73 +interrupted_a=$? +set -e +test "$interrupted_a" -eq 75 +"$binary" remap --input "$demo/fixtures/pages.jsonl" --output "$demo/resume-a-output.jsonl" --audit "$demo/resume-a-audit.jsonl" --resume +cmp "$demo/output.jsonl" "$demo/resume-a-output.jsonl" +cmp "$demo/audit.jsonl" "$demo/resume-a-audit.jsonl" + +set +e +"$binary" remap --input "$demo/fixtures/pages.jsonl" --output "$demo/resume-b-output.jsonl" --audit "$demo/resume-b-audit.jsonl" --stop-after 191 +interrupted_b=$? +set -e +test "$interrupted_b" -eq 75 +"$binary" remap --input "$demo/fixtures/pages.jsonl" --output "$demo/resume-b-output.jsonl" --audit "$demo/resume-b-audit.jsonl" --resume +cmp "$demo/output.jsonl" "$demo/resume-b-output.jsonl" +cmp "$demo/audit.jsonl" "$demo/resume-b-audit.jsonl" + +echo "demo passed: exact rectangles, content-free output, duplicate identity, and two resumes" diff --git a/developer-simulation/runs/2026-08-04--ocr-redaction-remap/src/lib.rs b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/src/lib.rs new file mode 100644 index 0000000..7a2e6e6 --- /dev/null +++ b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/src/lib.rs @@ -0,0 +1,2125 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, BufWriter, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use unicode_normalization::UnicodeNormalization; +use unicode_segmentation::UnicodeSegmentation; + +const MAX_EDIT_DISTANCE: usize = 256; +const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PageInput { + page_id: String, + old_text: String, + revised_text: String, + glyphs: Vec, + spans: Vec, +} + +/// Compact JSON form: [start_byte, end_byte, line, x, y, width, height]. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +struct Glyph(usize, usize, u32, i32, i32, i32, i32); + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +struct ReviewedSpan { + start: usize, + end: usize, + reason: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +struct Rect { + x: i32, + y: i32, + w: i32, + h: i32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct PageOutput { + page_id: String, + status: String, + rectangles: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + error_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct PageAudit { + page_id: String, + status: String, + reviewed_spans: usize, + exact_spans: usize, + conservative_spans: usize, + removed_spans: usize, + rectangle_count: usize, + reason_codes: Vec, + decisions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + error_code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Checkpoint { + completed_pages: usize, + output_len: u64, + audit_len: u64, + input_prefix_hash: u64, + output_prefix_hash: u64, + audit_prefix_hash: u64, + blocked_pages: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CompletionMarker { + version: u32, + input_path: String, + output_path: String, + audit_path: String, + input_len: u64, + output_len: u64, + audit_len: u64, + input_sha256: String, + output_sha256: String, + audit_sha256: String, + completed_pages: usize, + blocked_pages: usize, +} + +#[derive(Debug)] +struct SafeError { + code: &'static str, + line: Option, +} + +impl SafeError { + const fn new(code: &'static str) -> Self { + Self { code, line: None } + } + + const fn at_line(code: &'static str, line: usize) -> Self { + Self { + code, + line: Some(line), + } + } + + fn print(&self) { + match self.line { + Some(line) => eprintln!("error code={} line={line}", self.code), + None => eprintln!("error code={}", self.code), + } + } +} + +type SafeResult = Result; + +#[derive(Debug)] +struct RemapArgs { + input: PathBuf, + output: PathBuf, + audit: PathBuf, + resume: bool, + stop_after: Option, +} + +#[derive(Debug, Serialize)] +struct RunSummary { + pages: usize, + blocked_pages: usize, + resumed_from: usize, + interrupted: bool, +} + +pub fn entry(args: Vec) -> ExitCode { + let result = dispatch(&args); + match result { + Ok(CommandResult::Complete(summary)) => { + let _ = serde_json::to_writer(std::io::stdout().lock(), &summary); + println!(); + if summary.blocked_pages == 0 { + ExitCode::SUCCESS + } else { + ExitCode::from(2) + } + } + Ok(CommandResult::Interrupted(summary)) => { + let _ = serde_json::to_writer(std::io::stdout().lock(), &summary); + println!(); + ExitCode::from(75) + } + Ok(CommandResult::Generated(summary)) | Ok(CommandResult::Checked(summary)) => { + println!("{summary}"); + ExitCode::SUCCESS + } + Err(error) => { + error.print(); + ExitCode::from(2) + } + } +} + +#[derive(Debug)] +enum CommandResult { + Complete(RunSummary), + Interrupted(RunSummary), + Generated(String), + Checked(String), +} + +fn dispatch(args: &[String]) -> SafeResult { + let Some(command) = args.first().map(String::as_str) else { + return Err(SafeError::new("usage")); + }; + match command { + "remap" => { + let parsed = parse_remap_args(&args[1..])?; + run_remap(&parsed) + } + "generate" => { + let dir = required_path_arg(&args[1..], "--dir")?; + let pages = optional_usize_arg(&args[1..], "--pages")?.unwrap_or(240); + generate_suite(&dir, pages)?; + Ok(CommandResult::Generated(format!( + "generated fixture_pages={} hand_pages=4", + pages + ))) + } + "generate-workload" => { + let output = required_path_arg(&args[1..], "--output")?; + let pages = optional_usize_arg(&args[1..], "--pages")?.unwrap_or(5_000); + let scalars = optional_usize_arg(&args[1..], "--scalars-per-page")?.unwrap_or(4_000); + let spans = optional_usize_arg(&args[1..], "--spans-per-page")?.unwrap_or(30); + generate_workload(&output, pages, scalars, spans)?; + Ok(CommandResult::Generated(format!( + "generated workload_pages={pages} scalars={} spans={}", + pages.saturating_mul(scalars), + pages.saturating_mul(spans) + ))) + } + "check" => { + let input = required_path_arg(&args[1..], "--input")?; + let output = required_path_arg(&args[1..], "--output")?; + let audit = required_path_arg(&args[1..], "--audit")?; + let expected = required_path_arg(&args[1..], "--expected")?; + let sentinels = required_path_arg(&args[1..], "--sentinels")?; + let diagnostics = optional_path_arg(&args[1..], "--diagnostics"); + let summary = check_suite( + &input, + &output, + &audit, + &expected, + &sentinels, + diagnostics.as_deref(), + )?; + Ok(CommandResult::Checked(summary)) + } + _ => Err(SafeError::new("usage")), + } +} + +fn parse_remap_args(args: &[String]) -> SafeResult { + Ok(RemapArgs { + input: required_path_arg(args, "--input")?, + output: required_path_arg(args, "--output")?, + audit: required_path_arg(args, "--audit")?, + resume: args.iter().any(|arg| arg == "--resume"), + stop_after: optional_usize_arg(args, "--stop-after")?, + }) +} + +fn required_path_arg(args: &[String], name: &str) -> SafeResult { + optional_path_arg(args, name).ok_or_else(|| SafeError::new("usage")) +} + +fn optional_path_arg(args: &[String], name: &str) -> Option { + args.windows(2) + .find(|pair| pair[0] == name) + .map(|pair| PathBuf::from(&pair[1])) +} + +fn optional_usize_arg(args: &[String], name: &str) -> SafeResult> { + let Some(raw) = args + .windows(2) + .find(|pair| pair[0] == name) + .map(|pair| pair[1].as_str()) + else { + return Ok(None); + }; + raw.parse::() + .map(Some) + .map_err(|_| SafeError::new("usage")) +} + +fn run_remap(args: &RemapArgs) -> SafeResult { + if args.output == args.audit || args.input == args.output || args.input == args.audit { + return Err(SafeError::new("path_conflict")); + } + let output_partial = suffixed(&args.output, ".partial"); + let audit_partial = suffixed(&args.audit, ".partial"); + let checkpoint_path = suffixed(&args.output, ".checkpoint"); + let complete_path = suffixed(&args.output, ".complete"); + + if args.resume && complete_path.exists() { + let marker: CompletionMarker = read_json_file(&complete_path, "invalid_completion_marker")?; + verify_completion_marker(args, &marker)?; + return Ok(CommandResult::Complete(RunSummary { + pages: marker.completed_pages, + blocked_pages: marker.blocked_pages, + resumed_from: marker.completed_pages, + interrupted: false, + })); + } + + let (mut checkpoint, resumed_from) = if args.resume && checkpoint_path.exists() { + let checkpoint: Checkpoint = read_json_file(&checkpoint_path, "invalid_checkpoint")?; + (checkpoint.clone(), checkpoint.completed_pages) + } else { + remove_if_exists(&complete_path)?; + remove_if_exists(&checkpoint_path)?; + remove_if_exists(&output_partial)?; + remove_if_exists(&audit_partial)?; + Checkpoint { + completed_pages: 0, + output_len: 0, + audit_len: 0, + input_prefix_hash: FNV_OFFSET, + output_prefix_hash: FNV_OFFSET, + audit_prefix_hash: FNV_OFFSET, + blocked_pages: 0, + } + .pipe(|checkpoint| (checkpoint, 0)) + }; + + let input_file = File::open(&args.input).map_err(|_| SafeError::new("input_open"))?; + let mut reader = BufReader::new(input_file); + if resumed_from > 0 { + restore_partial_for_resume(&output_partial, &args.output)?; + restore_partial_for_resume(&audit_partial, &args.audit)?; + validate_partial( + &output_partial, + checkpoint.output_len, + checkpoint.output_prefix_hash, + )?; + validate_partial( + &audit_partial, + checkpoint.audit_len, + checkpoint.audit_prefix_hash, + )?; + } + let output_file = open_partial(&output_partial, resumed_from > 0, checkpoint.output_len)?; + let audit_file = open_partial(&audit_partial, resumed_from > 0, checkpoint.audit_len)?; + + let mut raw = Vec::new(); + let mut prefix_hash = FNV_OFFSET; + for line_index in 1..=checkpoint.completed_pages { + raw.clear(); + if reader + .read_until(b'\n', &mut raw) + .map_err(|_| SafeError::at_line("input_read", line_index))? + == 0 + { + return Err(SafeError::new("resume_input_shorter")); + } + prefix_hash = fnv_update(prefix_hash, &raw); + } + if prefix_hash != checkpoint.input_prefix_hash { + return Err(SafeError::new("resume_input_changed")); + } + + let mut output_writer = BufWriter::new(output_file); + let mut audit_writer = BufWriter::new(audit_file); + let mut line_index = checkpoint.completed_pages; + loop { + raw.clear(); + let bytes = reader + .read_until(b'\n', &mut raw) + .map_err(|_| SafeError::at_line("input_read", line_index + 1))?; + if bytes == 0 { + break; + } + line_index += 1; + let content = raw.strip_suffix(b"\n").unwrap_or(&raw); + let content = content.strip_suffix(b"\r").unwrap_or(content); + if content.is_empty() { + return Err(SafeError::at_line("blank_input_line", line_index)); + } + std::str::from_utf8(content).map_err(|_| SafeError::at_line("invalid_utf8", line_index))?; + let page: PageInput = serde_json::from_slice(content) + .map_err(|_| SafeError::at_line("invalid_json", line_index))?; + let (output, audit) = process_page(page, line_index); + let mut output_record = + serde_json::to_vec(&output).map_err(|_| SafeError::new("output_write"))?; + output_record.push(b'\n'); + output_writer + .write_all(&output_record) + .map_err(|_| SafeError::new("output_write"))?; + let mut audit_record = + serde_json::to_vec(&audit).map_err(|_| SafeError::new("audit_write"))?; + audit_record.push(b'\n'); + audit_writer + .write_all(&audit_record) + .map_err(|_| SafeError::new("audit_write"))?; + output_writer + .flush() + .map_err(|_| SafeError::new("output_write"))?; + audit_writer + .flush() + .map_err(|_| SafeError::new("audit_write"))?; + + checkpoint.completed_pages += 1; + checkpoint.output_len = output_writer + .stream_position() + .map_err(|_| SafeError::new("output_write"))?; + checkpoint.audit_len = audit_writer + .stream_position() + .map_err(|_| SafeError::new("audit_write"))?; + checkpoint.input_prefix_hash = fnv_update(checkpoint.input_prefix_hash, &raw); + checkpoint.output_prefix_hash = fnv_update(checkpoint.output_prefix_hash, &output_record); + checkpoint.audit_prefix_hash = fnv_update(checkpoint.audit_prefix_hash, &audit_record); + if output.status == "blocked" { + checkpoint.blocked_pages += 1; + } + write_checkpoint(&checkpoint_path, &checkpoint)?; + + if args.stop_after == Some(checkpoint.completed_pages) { + return Ok(CommandResult::Interrupted(RunSummary { + pages: checkpoint.completed_pages, + blocked_pages: checkpoint.blocked_pages, + resumed_from, + interrupted: true, + })); + } + } + + output_writer + .flush() + .map_err(|_| SafeError::new("output_write"))?; + audit_writer + .flush() + .map_err(|_| SafeError::new("audit_write"))?; + output_writer + .get_ref() + .sync_all() + .map_err(|_| SafeError::new("output_write"))?; + audit_writer + .get_ref() + .sync_all() + .map_err(|_| SafeError::new("audit_write"))?; + drop(output_writer); + drop(audit_writer); + replace_file(&output_partial, &args.output)?; + replace_file(&audit_partial, &args.audit)?; + let marker = completion_marker(args, &checkpoint)?; + write_checkpoint(&complete_path, &marker)?; + remove_if_exists(&checkpoint_path)?; + + Ok(CommandResult::Complete(RunSummary { + pages: checkpoint.completed_pages, + blocked_pages: checkpoint.blocked_pages, + resumed_from, + interrupted: false, + })) +} + +trait Pipe: Sized { + fn pipe(self, f: impl FnOnce(Self) -> T) -> T { + f(self) + } +} +impl Pipe for T {} + +fn suffixed(path: &Path, suffix: &str) -> PathBuf { + let mut value = path.as_os_str().to_os_string(); + value.push(suffix); + PathBuf::from(value) +} + +fn open_partial(path: &Path, resume: bool, len: u64) -> SafeResult { + let mut options = OpenOptions::new(); + options.read(true).write(true); + if resume { + let mut file = options + .open(path) + .map_err(|_| SafeError::new("partial_open"))?; + let actual_len = file + .metadata() + .map_err(|_| SafeError::new("partial_open"))? + .len(); + if actual_len < len { + return Err(SafeError::new("partial_short")); + } + file.set_len(len) + .map_err(|_| SafeError::new("partial_write"))?; + file.seek(SeekFrom::Start(len)) + .map_err(|_| SafeError::new("partial_write"))?; + Ok(file) + } else { + options + .create(true) + .truncate(true) + .open(path) + .map_err(|_| SafeError::new("partial_open")) + } +} + +fn restore_partial_for_resume(partial: &Path, final_path: &Path) -> SafeResult<()> { + if partial.exists() { + return Ok(()); + } + if final_path.exists() { + fs::rename(final_path, partial).map_err(|_| SafeError::new("resume_partial_restore")) + } else { + Err(SafeError::new("resume_partial_missing")) + } +} + +fn fnv_update(mut hash: u64, bytes: &[u8]) -> u64 { + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} + +fn validate_partial(path: &Path, expected_len: u64, expected_hash: u64) -> SafeResult<()> { + let file = File::open(path).map_err(|_| SafeError::new("partial_open"))?; + let actual_len = file + .metadata() + .map_err(|_| SafeError::new("partial_open"))? + .len(); + if actual_len < expected_len { + return Err(SafeError::new("partial_short")); + } + let mut reader = file.take(expected_len); + let mut buffer = [0_u8; 8192]; + let mut hash = FNV_OFFSET; + loop { + let read = reader + .read(&mut buffer) + .map_err(|_| SafeError::new("partial_read"))?; + if read == 0 { + break; + } + hash = fnv_update(hash, &buffer[..read]); + } + if hash != expected_hash { + return Err(SafeError::new("partial_changed")); + } + Ok(()) +} + +fn path_identity(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn file_len(path: &Path) -> SafeResult { + fs::metadata(path) + .map(|metadata| metadata.len()) + .map_err(|_| SafeError::new("completed_state_mismatch")) +} + +fn sha256_file(path: &Path) -> SafeResult { + let mut file = File::open(path).map_err(|_| SafeError::new("completed_state_mismatch"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(|_| SafeError::new("completed_state_mismatch"))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn completion_marker(args: &RemapArgs, checkpoint: &Checkpoint) -> SafeResult { + Ok(CompletionMarker { + version: 1, + input_path: path_identity(&args.input), + output_path: path_identity(&args.output), + audit_path: path_identity(&args.audit), + input_len: file_len(&args.input)?, + output_len: file_len(&args.output)?, + audit_len: file_len(&args.audit)?, + input_sha256: sha256_file(&args.input)?, + output_sha256: sha256_file(&args.output)?, + audit_sha256: sha256_file(&args.audit)?, + completed_pages: checkpoint.completed_pages, + blocked_pages: checkpoint.blocked_pages, + }) +} + +fn verify_completion_marker(args: &RemapArgs, marker: &CompletionMarker) -> SafeResult<()> { + let paths_match = marker.version == 1 + && marker.input_path == path_identity(&args.input) + && marker.output_path == path_identity(&args.output) + && marker.audit_path == path_identity(&args.audit); + let lengths_match = file_len(&args.input)? == marker.input_len + && file_len(&args.output)? == marker.output_len + && file_len(&args.audit)? == marker.audit_len; + let hashes_match = sha256_file(&args.input)? == marker.input_sha256 + && sha256_file(&args.output)? == marker.output_sha256 + && sha256_file(&args.audit)? == marker.audit_sha256; + if paths_match && lengths_match && hashes_match { + Ok(()) + } else { + Err(SafeError::new("completed_state_mismatch")) + } +} + +fn read_json_file Deserialize<'de>>(path: &Path, code: &'static str) -> SafeResult { + let file = File::open(path).map_err(|_| SafeError::new(code))?; + serde_json::from_reader(BufReader::new(file)).map_err(|_| SafeError::new(code)) +} + +fn write_checkpoint(path: &Path, checkpoint: &T) -> SafeResult<()> { + let temp = suffixed(path, ".tmp"); + let file = File::create(&temp).map_err(|_| SafeError::new("checkpoint_write"))?; + let mut writer = BufWriter::new(file); + serde_json::to_writer(&mut writer, checkpoint) + .map_err(|_| SafeError::new("checkpoint_write"))?; + writer + .write_all(b"\n") + .map_err(|_| SafeError::new("checkpoint_write"))?; + writer + .flush() + .map_err(|_| SafeError::new("checkpoint_write"))?; + writer + .get_ref() + .sync_all() + .map_err(|_| SafeError::new("checkpoint_write"))?; + replace_file(&temp, path) +} + +fn replace_file(from: &Path, to: &Path) -> SafeResult<()> { + remove_if_exists(to)?; + fs::rename(from, to).map_err(|_| SafeError::new("atomic_rename")) +} + +fn remove_if_exists(path: &Path) -> SafeResult<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(SafeError::new("file_remove")), + } +} + +#[derive(Debug, Clone)] +struct GraphemeInfo { + start: usize, + end: usize, + is_line_break: bool, +} + +#[derive(Debug, Clone)] +struct Token { + ch: char, + grapheme_start: usize, + grapheme_end: usize, + had_newline: bool, +} + +#[derive(Debug, Clone, Copy)] +enum EditOp { + Equal(usize, usize), + Delete(usize), + Insert(usize), +} + +#[derive(Debug)] +enum SpanDecision { + Exact(BTreeSet), + ConservativeToken(BTreeSet), + ConservativeLine(BTreeSet), + Removed, + Blocked(&'static str), +} + +fn process_page(page: PageInput, line_number: usize) -> (PageOutput, PageAudit) { + let safe_id = if valid_identifier(&page.page_id) { + page.page_id.clone() + } else { + format!("line-{line_number:08}") + }; + if !valid_identifier(&page.page_id) { + return blocked_page(safe_id, "invalid_page_id"); + } + + let spans: Vec = page + .spans + .into_iter() + .collect::>() + .into_iter() + .collect(); + if spans.iter().any(|span| !valid_identifier(&span.reason)) { + return blocked_page(safe_id, "invalid_reason_code"); + } + if let Some(code) = validate_spans(&page.old_text, &spans) { + return blocked_page(safe_id, code); + } + + let revised_graphemes = graphemes(&page.revised_text); + let glyphs = match validate_geometry(&page.revised_text, &revised_graphemes, &page.glyphs) { + Ok(glyphs) => glyphs, + Err(code) => return blocked_page(safe_id, code), + }; + let old_graphemes = graphemes(&page.old_text); + let old_tokens = canonical_tokens(&page.old_text, &old_graphemes); + let revised_tokens = canonical_tokens(&page.revised_text, &revised_graphemes); + let old_chars: Vec = old_tokens.iter().map(|token| token.ch).collect(); + let revised_chars: Vec = revised_tokens.iter().map(|token| token.ch).collect(); + let mut edit_script: Option, &'static str>> = None; + + let mut rectangle_set = BTreeSet::new(); + let mut reason_codes = BTreeSet::new(); + let mut decisions = Vec::new(); + let mut exact_spans = 0; + let mut conservative_spans = 0; + let mut removed_spans = 0; + let mut page_status = "exact"; + + for span in &spans { + reason_codes.insert(span.reason.clone()); + let Some((token_start, token_end)) = + token_interval_for_bytes(&old_tokens, &old_graphemes, span.start, span.end) + else { + return blocked_page(safe_id, "empty_span_after_normalization"); + }; + let pattern = &old_chars[token_start..token_end]; + let occurrences = find_occurrences(&revised_chars, pattern); + let decision = if !occurrences.is_empty() { + let literal_decision = if occurrences.len() == 1 { + SpanDecision::Exact(token_range_graphemes( + &revised_tokens, + occurrences[0], + occurrences[0] + pattern.len(), + )) + } else { + let scored: Vec<(usize, usize)> = occurrences + .iter() + .map(|&candidate| { + ( + candidate, + context_score( + &old_chars, + token_start, + token_end, + &revised_chars, + candidate, + candidate + pattern.len(), + ), + ) + }) + .collect(); + let best = scored.iter().map(|(_, score)| *score).max().unwrap_or(0); + let winners: Vec = scored + .iter() + .filter(|(_, score)| *score == best) + .map(|(candidate, _)| *candidate) + .collect(); + if best > 0 && winners.len() == 1 { + SpanDecision::Exact(token_range_graphemes( + &revised_tokens, + winners[0], + winners[0] + pattern.len(), + )) + } else { + let mut all = BTreeSet::new(); + for candidate in occurrences { + all.extend(token_range_graphemes( + &revised_tokens, + candidate, + candidate + pattern.len(), + )); + } + SpanDecision::ConservativeToken(all) + } + }; + + let script_result = + edit_script.get_or_insert_with(|| myers_diff(&old_chars, &revised_chars)); + match (literal_decision, script_result) { + (SpanDecision::Exact(literal), Ok(script)) => { + let mapped = + map_span_through_edits(script, token_start, token_end, &revised_tokens); + if mapped == literal { + SpanDecision::Exact(literal) + } else { + let mut conservative = literal; + conservative.extend(mapped); + if conservative.is_empty() { + SpanDecision::Blocked("ambiguous_mapping") + } else { + SpanDecision::ConservativeToken(conservative) + } + } + } + (SpanDecision::ConservativeToken(mut literal), Ok(script)) => { + literal.extend(map_span_through_edits( + script, + token_start, + token_end, + &revised_tokens, + )); + if literal.is_empty() { + SpanDecision::Blocked("ambiguous_mapping") + } else { + SpanDecision::ConservativeToken(literal) + } + } + (_, Err(code)) => SpanDecision::Blocked(code), + _ => unreachable!("literal matching returns exact or conservative token"), + } + } else { + let script_result = + edit_script.get_or_insert_with(|| myers_diff(&old_chars, &revised_chars)); + match script_result { + Ok(script) => { + let mapped = + map_span_through_edits(script, token_start, token_end, &revised_tokens); + if unique_flanks(&old_chars, token_start, token_end, &revised_chars, &mapped) { + if mapped.is_empty() { + SpanDecision::Removed + } else { + SpanDecision::Exact(mapped) + } + } else { + let lines = lines_for_graphemes(&mapped, &revised_graphemes, &glyphs); + if lines.is_empty() { + SpanDecision::Blocked("ambiguous_mapping") + } else { + SpanDecision::ConservativeLine(lines) + } + } + } + Err(code) => SpanDecision::Blocked(code), + } + }; + + match decision { + SpanDecision::Exact(indices) => { + exact_spans += 1; + decisions.push("exact".to_string()); + add_glyph_rectangles(&mut rectangle_set, &indices, &glyphs); + } + SpanDecision::ConservativeToken(indices) => { + conservative_spans += 1; + page_status = "conservative"; + decisions.push("fallback_token".to_string()); + add_glyph_rectangles(&mut rectangle_set, &indices, &glyphs); + } + SpanDecision::ConservativeLine(lines) => { + conservative_spans += 1; + page_status = "conservative"; + decisions.push("fallback_line".to_string()); + for glyph in glyphs.values().filter(|glyph| lines.contains(&glyph.2)) { + rectangle_set.insert(glyph_rect(*glyph)); + } + } + SpanDecision::Removed => { + removed_spans += 1; + decisions.push("removed".to_string()); + } + SpanDecision::Blocked(code) => return blocked_page(safe_id, code), + } + } + + let mut rectangles: Vec = rectangle_set.into_iter().collect(); + rectangles.sort_by_key(|rect| (rect.y, rect.x, rect.w, rect.h)); + let output = PageOutput { + page_id: safe_id.clone(), + status: page_status.to_string(), + rectangles, + error_code: None, + }; + let audit = PageAudit { + page_id: safe_id, + status: page_status.to_string(), + reviewed_spans: spans.len(), + exact_spans, + conservative_spans, + removed_spans, + rectangle_count: output.rectangles.len(), + reason_codes: reason_codes.into_iter().collect(), + decisions, + error_code: None, + }; + (output, audit) +} + +fn blocked_page(page_id: String, code: &'static str) -> (PageOutput, PageAudit) { + ( + PageOutput { + page_id: page_id.clone(), + status: "blocked".to_string(), + rectangles: Vec::new(), + error_code: Some(code.to_string()), + }, + PageAudit { + page_id, + status: "blocked".to_string(), + reviewed_spans: 0, + exact_spans: 0, + conservative_spans: 0, + removed_spans: 0, + rectangle_count: 0, + reason_codes: Vec::new(), + decisions: Vec::new(), + error_code: Some(code.to_string()), + }, + ) +} + +fn valid_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +fn validate_spans(text: &str, spans: &[ReviewedSpan]) -> Option<&'static str> { + for span in spans { + if span.start >= span.end || span.end > text.len() { + return Some("corrupt_offset"); + } + if !text.is_char_boundary(span.start) || !text.is_char_boundary(span.end) { + return Some("invalid_utf8_boundary"); + } + } + None +} + +fn graphemes(text: &str) -> Vec { + let mut result = Vec::new(); + for (start, grapheme) in text.grapheme_indices(true) { + result.push(GraphemeInfo { + start, + end: start + grapheme.len(), + is_line_break: grapheme.contains(['\n', '\r']), + }); + } + result +} + +fn canonical_tokens(text: &str, graphemes: &[GraphemeInfo]) -> Vec { + let mut raw = Vec::new(); + for (index, grapheme) in graphemes.iter().enumerate() { + let value = &text[grapheme.start..grapheme.end]; + for ch in value.nfkc() { + if ch == '\u{00ad}' { + continue; + } + let ch = if ch.is_whitespace() { ' ' } else { ch }; + if ch == ' ' && raw.last().is_some_and(|token: &Token| token.ch == ' ') { + let previous = raw.last_mut().expect("last token exists"); + previous.grapheme_end = index + 1; + previous.had_newline |= grapheme.is_line_break; + } else { + raw.push(Token { + ch, + grapheme_start: index, + grapheme_end: index + 1, + had_newline: grapheme.is_line_break, + }); + } + } + } + + let mut result = Vec::with_capacity(raw.len()); + let mut index = 0; + while index < raw.len() { + let dehyphenated = index + 2 < raw.len() + && raw[index].ch == '-' + && raw[index + 1].ch == ' ' + && raw[index + 1].had_newline + && result + .last() + .is_some_and(|token: &Token| token.ch.is_alphanumeric()) + && raw[index + 2].ch.is_alphanumeric(); + if dehyphenated { + index += 2; + } else { + result.push(raw[index].clone()); + index += 1; + } + } + result +} + +fn validate_geometry( + _text: &str, + graphemes: &[GraphemeInfo], + supplied: &[Glyph], +) -> Result, &'static str> { + let mut by_range = BTreeMap::new(); + for glyph in supplied { + if glyph.0 >= glyph.1 || glyph.5 <= 0 || glyph.6 <= 0 { + return Err("contradictory_geometry"); + } + if by_range.insert((glyph.0, glyph.1), *glyph).is_some() { + return Err("contradictory_geometry"); + } + } + let mut result = BTreeMap::new(); + for (index, grapheme) in graphemes.iter().enumerate() { + if grapheme.is_line_break { + continue; + } + let Some(glyph) = by_range.remove(&(grapheme.start, grapheme.end)) else { + return Err("missing_geometry"); + }; + result.insert(index, glyph); + } + if by_range.is_empty() { + Ok(result) + } else { + Err("contradictory_geometry") + } +} + +fn token_interval_for_bytes( + tokens: &[Token], + graphemes: &[GraphemeInfo], + start: usize, + end: usize, +) -> Option<(usize, usize)> { + let mut first = None; + let mut last = None; + for (index, token) in tokens.iter().enumerate() { + let token_start = graphemes[token.grapheme_start].start; + let token_end = graphemes[token.grapheme_end - 1].end; + if token_start < end && token_end > start { + first.get_or_insert(index); + last = Some(index + 1); + } + } + first.zip(last) +} + +fn find_occurrences(haystack: &[char], needle: &[char]) -> Vec { + if needle.is_empty() || needle.len() > haystack.len() { + return Vec::new(); + } + let first = needle[0]; + haystack + .windows(needle.len()) + .enumerate() + .filter(|(_, window)| window[0] == first && *window == needle) + .map(|(index, _)| index) + .collect() +} + +fn context_score( + old: &[char], + old_start: usize, + old_end: usize, + revised: &[char], + revised_start: usize, + revised_end: usize, +) -> usize { + let mut score = 0; + for distance in 1..=32 { + if old_start < distance || revised_start < distance { + break; + } + if old[old_start - distance] != revised[revised_start - distance] { + break; + } + score += 1; + } + for distance in 0..32 { + if old_end + distance >= old.len() || revised_end + distance >= revised.len() { + break; + } + if old[old_end + distance] != revised[revised_end + distance] { + break; + } + score += 1; + } + score +} + +fn token_range_graphemes(tokens: &[Token], start: usize, end: usize) -> BTreeSet { + let mut result = BTreeSet::new(); + for token in &tokens[start..end] { + result.extend(token.grapheme_start..token.grapheme_end); + } + result +} + +fn myers_diff(old: &[char], revised: &[char]) -> Result, &'static str> { + let max_distance = old + .len() + .saturating_add(revised.len()) + .min(MAX_EDIT_DISTANCE); + let mut rows: Vec> = Vec::with_capacity(max_distance + 1); + for distance in 0..=max_distance { + let mut row = vec![-1_i32; distance.saturating_mul(2) + 1]; + for diagonal in (-(distance as isize)..=distance as isize).step_by(2) { + let mut x = if distance == 0 { + 0 + } else if diagonal == -(distance as isize) { + row_get(&rows[distance - 1], distance - 1, diagonal + 1) + } else if diagonal == distance as isize { + row_get(&rows[distance - 1], distance - 1, diagonal - 1) + 1 + } else { + let deletion = row_get(&rows[distance - 1], distance - 1, diagonal - 1) + 1; + let insertion = row_get(&rows[distance - 1], distance - 1, diagonal + 1); + if deletion >= insertion { + deletion + } else { + insertion + } + }; + let mut y = x - i32::try_from(diagonal).expect("edit diagonal fits i32"); + while x >= 0 + && y >= 0 + && (x as usize) < old.len() + && (y as usize) < revised.len() + && old[x as usize] == revised[y as usize] + { + x += 1; + y += 1; + } + row_set(&mut row, distance, diagonal, x); + if x as usize >= old.len() && y as usize >= revised.len() { + rows.push(row); + return Ok(backtrack_myers(&rows, old.len(), revised.len())); + } + } + rows.push(row); + } + Err("edit_distance_limit") +} + +fn row_get(row: &[i32], distance: usize, diagonal: isize) -> i32 { + let index = diagonal + distance as isize; + if index < 0 || index as usize >= row.len() { + -1 + } else { + row[index as usize] + } +} + +fn row_set(row: &mut [i32], distance: usize, diagonal: isize, value: i32) { + let index = (diagonal + distance as isize) as usize; + row[index] = value; +} + +fn backtrack_myers(rows: &[Vec], old_len: usize, revised_len: usize) -> Vec { + let mut x = old_len as isize; + let mut y = revised_len as isize; + let mut reverse = Vec::with_capacity(old_len.saturating_add(revised_len)); + for distance in (1..rows.len()).rev() { + let diagonal = x - y; + let previous = &rows[distance - 1]; + let previous_diagonal = if diagonal == -(distance as isize) + || (diagonal != distance as isize + && row_get(previous, distance - 1, diagonal - 1) + < row_get(previous, distance - 1, diagonal + 1)) + { + diagonal + 1 + } else { + diagonal - 1 + }; + let previous_x = isize::try_from(row_get(previous, distance - 1, previous_diagonal)) + .expect("stored edit coordinate fits isize"); + let previous_y = previous_x - previous_diagonal; + while x > previous_x && y > previous_y { + reverse.push(EditOp::Equal((x - 1) as usize, (y - 1) as usize)); + x -= 1; + y -= 1; + } + if x == previous_x { + reverse.push(EditOp::Insert((y - 1) as usize)); + y -= 1; + } else { + reverse.push(EditOp::Delete((x - 1) as usize)); + x -= 1; + } + } + while x > 0 && y > 0 { + reverse.push(EditOp::Equal((x - 1) as usize, (y - 1) as usize)); + x -= 1; + y -= 1; + } + while x > 0 { + reverse.push(EditOp::Delete((x - 1) as usize)); + x -= 1; + } + while y > 0 { + reverse.push(EditOp::Insert((y - 1) as usize)); + y -= 1; + } + reverse.reverse(); + reverse +} + +fn map_span_through_edits( + script: &[EditOp], + span_start: usize, + span_end: usize, + revised_tokens: &[Token], +) -> BTreeSet { + let mut revised_indices = BTreeSet::new(); + let mut index = 0; + let mut old_cursor = 0; + while index < script.len() { + match script[index] { + EditOp::Equal(old_index, revised_index) => { + if (span_start..span_end).contains(&old_index) { + revised_indices.insert(revised_index); + } + old_cursor = old_index + 1; + index += 1; + } + EditOp::Delete(_) | EditOp::Insert(_) => { + let insertion_point = old_cursor; + let mut deleted = Vec::new(); + let mut inserted = Vec::new(); + while index < script.len() { + match script[index] { + EditOp::Delete(old_index) => { + deleted.push(old_index); + old_cursor = old_index + 1; + } + EditOp::Insert(revised_index) => inserted.push(revised_index), + EditOp::Equal(_, _) => break, + } + index += 1; + } + let touches_deleted = deleted + .iter() + .any(|old_index| (span_start..span_end).contains(old_index)); + let inserted_inside = insertion_point > span_start && insertion_point < span_end; + if touches_deleted || inserted_inside { + revised_indices.extend(inserted); + } + } + } + } + let mut grapheme_indices = BTreeSet::new(); + for revised_index in revised_indices { + if let Some(token) = revised_tokens.get(revised_index) { + grapheme_indices.extend(token.grapheme_start..token.grapheme_end); + } + } + grapheme_indices +} + +fn unique_flanks( + old: &[char], + span_start: usize, + span_end: usize, + revised: &[char], + mapped_graphemes: &BTreeSet, +) -> bool { + let left = find_unique_left_flank(old, revised, span_start); + let right = find_unique_right_flank(old, revised, span_end); + let left_ok = span_start == 0 || left.is_some(); + let right_ok = span_end == old.len() || right.is_some(); + if !left_ok || !right_ok { + return false; + } + match (left, right) { + // A deleted token can cause the deterministic diff to assign one + // shared boundary whitespace to either flank. + (Some(left_end), Some(right_start)) => left_end <= right_start.saturating_add(1), + _ => !mapped_graphemes.is_empty() || old.is_empty() || revised.is_empty(), + } +} + +fn find_unique_left_flank(old: &[char], revised: &[char], span_start: usize) -> Option { + for distance in 0..=64 { + let end = span_start.checked_sub(distance)?; + for width in (4..=8).rev() { + let Some(start) = end.checked_sub(width) else { + continue; + }; + let needle = &old[start..end]; + if find_occurrences(old, needle).len() == 1 { + let matches = find_occurrences(revised, needle); + if matches.len() == 1 { + return Some(matches[0] + width); + } + } + } + } + None +} + +fn find_unique_right_flank(old: &[char], revised: &[char], span_end: usize) -> Option { + for distance in 0..=64 { + let start = span_end.saturating_add(distance); + if start >= old.len() { + break; + } + for width in (4..=8).rev() { + let end = start.saturating_add(width); + if end > old.len() { + continue; + } + let needle = &old[start..end]; + if find_occurrences(old, needle).len() == 1 { + let matches = find_occurrences(revised, needle); + if matches.len() == 1 { + return Some(matches[0]); + } + } + } + } + None +} + +fn lines_for_graphemes( + indices: &BTreeSet, + _graphemes: &[GraphemeInfo], + glyphs: &BTreeMap, +) -> BTreeSet { + indices + .iter() + .filter_map(|index| glyphs.get(index).map(|glyph| glyph.2)) + .collect() +} + +fn add_glyph_rectangles( + rectangles: &mut BTreeSet, + indices: &BTreeSet, + glyphs: &BTreeMap, +) { + for index in indices { + if let Some(glyph) = glyphs.get(index) { + rectangles.insert(glyph_rect(*glyph)); + } + } +} + +const fn glyph_rect(glyph: Glyph) -> Rect { + Rect { + x: glyph.3, + y: glyph.4, + w: glyph.5, + h: glyph.6, + } +} + +fn generate_suite(dir: &Path, generated_pages: usize) -> SafeResult<()> { + if generated_pages < 200 { + return Err(SafeError::new("fixture_count_below_acceptance")); + } + fs::create_dir_all(dir).map_err(|_| SafeError::new("generator_directory"))?; + let malformed_dir = dir.join("malformed"); + fs::create_dir_all(&malformed_dir).map_err(|_| SafeError::new("generator_directory"))?; + let pages_path = dir.join("pages.jsonl"); + let variant_path = dir.join("pages_shuffled_duplicated.jsonl"); + let expected_path = dir.join("expected.jsonl"); + let sentinel_path = dir.join("sentinels.json"); + let mut pages_writer = + BufWriter::new(File::create(&pages_path).map_err(|_| SafeError::new("generator_write"))?); + let mut variant_writer = + BufWriter::new(File::create(&variant_path).map_err(|_| SafeError::new("generator_write"))?); + let mut expected_writer = BufWriter::new( + File::create(&expected_path).map_err(|_| SafeError::new("generator_write"))?, + ); + let mut sentinels = Vec::new(); + + for index in 0..generated_pages { + let (page, expected, sentinel) = generated_case(index); + write_json_line(&mut pages_writer, &page)?; + let mut variant = page.clone(); + variant.spans.reverse(); + if let Some(first) = variant.spans.first().cloned() { + variant.spans.push(first); + } + write_json_line(&mut variant_writer, &variant)?; + write_json_line(&mut expected_writer, &expected)?; + sentinels.push(sentinel); + } + for hand in hand_cases() { + write_json_line(&mut pages_writer, &hand.page)?; + let mut variant = hand.page.clone(); + variant.spans.reverse(); + variant.spans.extend(variant.spans.clone()); + write_json_line(&mut variant_writer, &variant)?; + write_json_line(&mut expected_writer, &hand.expected)?; + sentinels.extend(hand.sentinels); + } + pages_writer + .flush() + .map_err(|_| SafeError::new("generator_write"))?; + variant_writer + .flush() + .map_err(|_| SafeError::new("generator_write"))?; + expected_writer + .flush() + .map_err(|_| SafeError::new("generator_write"))?; + let sentinel_file = + File::create(sentinel_path).map_err(|_| SafeError::new("generator_write"))?; + serde_json::to_writer(BufWriter::new(sentinel_file), &sentinels) + .map_err(|_| SafeError::new("generator_write"))?; + + write_malformed_cases(&malformed_dir)?; + Ok(()) +} + +#[derive(Debug)] +struct HandCase { + page: PageInput, + expected: PageOutput, + sentinels: Vec, +} + +fn generated_case(index: usize) -> (PageInput, PageOutput, String) { + let serial = format!("{index:04}"); + let (old_text, revised_text, old_needle, revised_needle, occurrence) = match index % 10 { + 0 => { + let secret = format!("SECRET_ALPHA_{serial}"); + ( + format!("alpha {secret} omega"), + format!("alpha {secret} omega"), + secret.clone(), + secret, + 0, + ) + } + 1 => { + let secret = format!("SECRET_SHIFT_{serial}"); + ( + format!("alpha removable words {secret} omega"), + format!("alpha {secret} omega"), + secret.clone(), + secret, + 0, + ) + } + 2 => { + let old_secret = format!("SECRET_CAFE_{serial}_e\u{301}"); + let revised_secret = format!("SECRET_CAFE_{serial}_é"); + ( + format!("alpha {old_secret} omega"), + format!("alpha {revised_secret} omega"), + old_secret, + revised_secret, + 0, + ) + } + 3 => { + let old_secret = format!("SECRET_SOFT\u{ad}HYPHEN_{serial}"); + let revised_secret = format!("SECRET_SOFTHYPHEN_{serial}"); + ( + format!("alpha {old_secret} omega"), + format!("alpha {revised_secret} omega"), + old_secret, + revised_secret, + 0, + ) + } + 4 => { + let old_secret = format!("SECRET_INTER-\nNAL_{serial}"); + let revised_secret = format!("SECRET_INTERNAL_{serial}"); + ( + format!("alpha {old_secret} omega"), + format!("alpha {revised_secret} omega"), + old_secret, + revised_secret, + 0, + ) + } + 5 => { + let old_secret = format!("SECRET WHITE {serial}"); + let revised_secret = format!("SECRET WHITE {serial}"); + ( + format!("alpha {old_secret} omega"), + format!("alpha {revised_secret} omega"), + old_secret, + revised_secret, + 0, + ) + } + 6 => { + let old_secret = format!("SECRET_fiLE_{serial}"); + let revised_secret = format!("SECRET_fiLE_{serial}"); + ( + format!("alpha {old_secret} omega"), + format!("alpha {revised_secret} omega"), + old_secret, + revised_secret, + 0, + ) + } + 7 => { + let old_secret = format!("SECR3T_OCR_{serial}"); + let revised_secret = format!("SECRET_OCR_{serial}"); + ( + format!("unique-left {old_secret} unique-right"), + format!("unique-left {revised_secret} unique-right"), + old_secret, + revised_secret, + 0, + ) + } + 8 => { + let secret = format!("秘密_é_{serial}"); + ( + format!("alpha {secret} omega"), + format!("alpha {secret} omega"), + secret.clone(), + secret, + 0, + ) + } + _ => { + let secret = format!("SECRET_REPEAT_{serial}"); + ( + format!("alpha {secret} omega then beta {secret} gamma"), + format!("alpha {secret} omega then beta {secret} gamma"), + secret.clone(), + secret, + 0, + ) + } + }; + let old_start = old_text + .find(&old_needle) + .expect("generated old needle exists"); + let page_id = format!("fixture-{index:04}"); + let glyphs = geometry(&revised_text); + let target_start = nth_find(&revised_text, &revised_needle, occurrence); + let rectangles = rectangles_for_ranges( + &glyphs, + &[(target_start, target_start + revised_needle.len())], + ); + let page = PageInput { + page_id: page_id.clone(), + old_text, + revised_text, + glyphs, + spans: vec![ReviewedSpan { + start: old_start, + end: old_start + old_needle.len(), + reason: "PUBLIC_RECORDS_RULE".to_string(), + }], + }; + let expected = PageOutput { + page_id, + status: "exact".to_string(), + rectangles, + error_code: None, + }; + (page, expected, revised_needle) +} + +fn hand_cases() -> Vec { + let mut cases = Vec::new(); + + let secret = "AMBIGUOUS_SECRET_X".to_string(); + let old_text = format!("left {secret} right"); + let revised_text = format!("{secret} gap {secret}"); + let glyphs = geometry(&revised_text); + let first = nth_find(&revised_text, &secret, 0); + let second = nth_find(&revised_text, &secret, 1); + cases.push(HandCase { + page: PageInput { + page_id: "hand-ambiguous-token".to_string(), + old_text: old_text.clone(), + revised_text: revised_text.clone(), + glyphs: glyphs.clone(), + spans: vec![ReviewedSpan { + start: old_text.find(&secret).expect("needle exists"), + end: old_text.find(&secret).expect("needle exists") + secret.len(), + reason: "PERSON_ID".to_string(), + }], + }, + expected: PageOutput { + page_id: "hand-ambiguous-token".to_string(), + status: "conservative".to_string(), + rectangles: rectangles_for_ranges( + &glyphs, + &[ + (first, first + secret.len()), + (second, second + secret.len()), + ], + ), + error_code: None, + }, + sentinels: vec![secret], + }); + + let secret = "OVERLAPPING_SECRET_Y".to_string(); + let text = format!("head {secret} tail"); + let secret_start = text.find(&secret).expect("needle exists"); + let glyphs = geometry(&text); + cases.push(HandCase { + page: PageInput { + page_id: "hand-overlap-duplicate".to_string(), + old_text: text.clone(), + revised_text: text.clone(), + glyphs: glyphs.clone(), + spans: vec![ + ReviewedSpan { + start: secret_start, + end: secret_start + secret.len(), + reason: "ACCOUNT_NUMBER".to_string(), + }, + ReviewedSpan { + start: secret_start + 4, + end: secret_start + secret.len(), + reason: "ACCOUNT_NUMBER".to_string(), + }, + ], + }, + expected: PageOutput { + page_id: "hand-overlap-duplicate".to_string(), + status: "exact".to_string(), + rectangles: rectangles_for_ranges( + &glyphs, + &[(secret_start, secret_start + secret.len())], + ), + error_code: None, + }, + sentinels: vec![secret], + }); + + let secret = "DELETED_SECRET_Z".to_string(); + let old_text = format!("unique-left {secret} unique-right"); + let revised_text = "unique-left unique-right".to_string(); + cases.push(HandCase { + page: PageInput { + page_id: "hand-deleted".to_string(), + old_text: old_text.clone(), + revised_text: revised_text.clone(), + glyphs: geometry(&revised_text), + spans: vec![ReviewedSpan { + start: old_text.find(&secret).expect("needle exists"), + end: old_text.find(&secret).expect("needle exists") + secret.len(), + reason: "PERSON_ID".to_string(), + }], + }, + expected: PageOutput { + page_id: "hand-deleted".to_string(), + status: "exact".to_string(), + rectangles: Vec::new(), + error_code: None, + }, + sentinels: vec![secret], + }); + + let secret = "BOUNDARY_SECRET_Q".to_string(); + let glyphs = geometry(&secret); + cases.push(HandCase { + page: PageInput { + page_id: "hand-whole-page".to_string(), + old_text: secret.clone(), + revised_text: secret.clone(), + glyphs: glyphs.clone(), + spans: vec![ReviewedSpan { + start: 0, + end: secret.len(), + reason: "SEALED_RECORD".to_string(), + }], + }, + expected: PageOutput { + page_id: "hand-whole-page".to_string(), + status: "exact".to_string(), + rectangles: rectangles_for_ranges(&glyphs, &[(0, secret.len())]), + error_code: None, + }, + sentinels: vec![secret], + }); + cases +} + +fn nth_find(haystack: &str, needle: &str, occurrence: usize) -> usize { + haystack + .match_indices(needle) + .nth(occurrence) + .map(|(index, _)| index) + .expect("generated revised needle exists") +} + +fn geometry(text: &str) -> Vec { + let mut result = Vec::new(); + let mut line = 0_u32; + let mut column = 0_i32; + for (start, value) in text.grapheme_indices(true) { + if value.contains(['\n', '\r']) { + line += 1; + column = 0; + continue; + } + result.push(Glyph( + start, + start + value.len(), + line, + column * 10, + i32::try_from(line).expect("fixture line fits i32") * 20, + 9, + 12, + )); + column += 1; + } + result +} + +fn rectangles_for_ranges(glyphs: &[Glyph], ranges: &[(usize, usize)]) -> Vec { + let mut result = BTreeSet::new(); + for glyph in glyphs { + if ranges + .iter() + .any(|(start, end)| glyph.0 < *end && glyph.1 > *start) + { + result.insert(glyph_rect(*glyph)); + } + } + let mut result: Vec<_> = result.into_iter().collect(); + result.sort_by_key(|rect| (rect.y, rect.x, rect.w, rect.h)); + result +} + +fn write_json_line(writer: &mut impl Write, value: &impl Serialize) -> SafeResult<()> { + serde_json::to_writer(&mut *writer, value).map_err(|_| SafeError::new("generator_write"))?; + writer + .write_all(b"\n") + .map_err(|_| SafeError::new("generator_write")) +} + +fn write_malformed_cases(dir: &Path) -> SafeResult<()> { + let text = "prefix 秘密 suffix".to_string(); + let start = text.find("秘密").expect("needle exists"); + let base = PageInput { + page_id: "malformed".to_string(), + old_text: text.clone(), + revised_text: text.clone(), + glyphs: geometry(&text), + spans: vec![ReviewedSpan { + start, + end: start + "秘密".len(), + reason: "PERSON_ID".to_string(), + }], + }; + write_single_page(&dir.join("valid.jsonl"), &base)?; + + let mut corrupt = base.clone(); + corrupt.page_id = "corrupt-offset".to_string(); + corrupt.spans[0].end = corrupt.old_text.len() + 1; + write_single_page(&dir.join("corrupt_offset.jsonl"), &corrupt)?; + + let mut boundary = base.clone(); + boundary.page_id = "invalid-boundary".to_string(); + boundary.spans[0].start = start + 1; + write_single_page(&dir.join("invalid_utf8_boundary.jsonl"), &boundary)?; + + let mut missing = base.clone(); + missing.page_id = "missing-geometry".to_string(); + missing.glyphs.pop(); + write_single_page(&dir.join("missing_geometry.jsonl"), &missing)?; + + let mut contradictory = base; + contradictory.page_id = "contradictory-geometry".to_string(); + contradictory.glyphs.push(contradictory.glyphs[0]); + write_single_page(&dir.join("contradictory_geometry.jsonl"), &contradictory)?; + + let invalid_path = dir.join("invalid_utf8.jsonl"); + let mut invalid = File::create(invalid_path).map_err(|_| SafeError::new("generator_write"))?; + invalid + .write_all(b"{\"page_id\":\"invalid-utf8\",\"old_text\":\"") + .map_err(|_| SafeError::new("generator_write"))?; + invalid + .write_all(&[0xff]) + .map_err(|_| SafeError::new("generator_write"))?; + invalid + .write_all(b"\"}\n") + .map_err(|_| SafeError::new("generator_write"))?; + Ok(()) +} + +fn write_single_page(path: &Path, page: &PageInput) -> SafeResult<()> { + let file = File::create(path).map_err(|_| SafeError::new("generator_write"))?; + let mut writer = BufWriter::new(file); + write_json_line(&mut writer, page)?; + writer + .flush() + .map_err(|_| SafeError::new("generator_write")) +} + +fn generate_workload(path: &Path, pages: usize, scalars: usize, spans: usize) -> SafeResult<()> { + if scalars < spans.saturating_mul(8) || spans > 100 { + return Err(SafeError::new("invalid_workload_shape")); + } + let file = File::create(path).map_err(|_| SafeError::new("generator_write"))?; + let mut writer = BufWriter::new(file); + for page_index in 0..pages { + let mut bytes = vec![b'a'; scalars]; + let mut reviewed = Vec::with_capacity(spans); + let stride = scalars / spans; + for span_index in 0..spans { + let token = format!("Q{span_index:02}Z"); + let start = span_index * stride + 2; + bytes[start..start + token.len()].copy_from_slice(token.as_bytes()); + reviewed.push(ReviewedSpan { + start, + end: start + token.len(), + reason: "PUBLIC_RECORDS_RULE".to_string(), + }); + } + let text = String::from_utf8(bytes).expect("ASCII fixture is UTF-8"); + let glyphs = (0..scalars) + .map(|index| { + Glyph( + index, + index + 1, + 0, + i32::try_from(index % 200).expect("workload column fits i32"), + i32::try_from(index / 200).expect("workload row fits i32"), + 1, + 1, + ) + }) + .collect(); + let page = PageInput { + page_id: format!("workload-{page_index:05}"), + old_text: text.clone(), + revised_text: text, + glyphs, + spans: reviewed, + }; + write_json_line(&mut writer, &page)?; + } + writer + .flush() + .map_err(|_| SafeError::new("generator_write")) +} + +fn check_suite( + input_path: &Path, + output_path: &Path, + audit_path: &Path, + expected_path: &Path, + sentinel_path: &Path, + diagnostics_path: Option<&Path>, +) -> SafeResult { + let expected: Vec = read_json_lines(expected_path, "checker_expected")?; + let actual: Vec = read_json_lines(output_path, "checker_output")?; + let audit: Vec = read_json_lines(audit_path, "checker_audit")?; + let input: Vec = read_json_lines(input_path, "checker_input")?; + if actual != expected { + return Err(SafeError::new("rectangle_mismatch")); + } + if actual.len() != audit.len() || actual.len() != input.len() || actual.len() < 200 { + return Err(SafeError::new("coverage_count")); + } + for (page, audit_page) in actual.iter().zip(&audit) { + if page.page_id != audit_page.page_id + || page.status != audit_page.status + || page.rectangles.len() != audit_page.rectangle_count + { + return Err(SafeError::new("audit_mismatch")); + } + } + let sentinels: Vec = read_json_file(sentinel_path, "checker_sentinels")?; + if sentinels.len() != input.len() { + return Err(SafeError::new("sentinel_coverage_count")); + } + let mut public_bytes = Vec::new(); + File::open(output_path) + .and_then(|mut file| file.read_to_end(&mut public_bytes)) + .map_err(|_| SafeError::new("checker_output"))?; + File::open(audit_path) + .and_then(|mut file| file.read_to_end(&mut public_bytes)) + .map_err(|_| SafeError::new("checker_audit"))?; + if let Some(path) = diagnostics_path { + File::open(path) + .and_then(|mut file| file.read_to_end(&mut public_bytes)) + .map_err(|_| SafeError::new("checker_diagnostics"))?; + } + for sentinel in &sentinels { + if contains_bytes(&public_bytes, sentinel.as_bytes()) { + return Err(SafeError::new("sentinel_leak")); + } + } + + let conservative = actual + .iter() + .filter(|page| page.status == "conservative") + .count(); + let exact = actual.iter().filter(|page| page.status == "exact").count(); + Ok(format!( + "checked pages={} exact={} conservative={} sentinel_leaks=0 rectangle_mismatches=0", + actual.len(), + exact, + conservative + )) +} + +fn read_json_lines Deserialize<'de>>( + path: &Path, + code: &'static str, +) -> SafeResult> { + let file = File::open(path).map_err(|_| SafeError::new(code))?; + let mut result = Vec::new(); + for line in BufReader::new(file).split(b'\n') { + let line = line.map_err(|_| SafeError::new(code))?; + if line.is_empty() { + continue; + } + let value = serde_json::from_slice(&line).map_err(|_| SafeError::new(code))?; + result.push(value); + } + Ok(result) +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn myers_script_reconstructs_revised_sequence() { + let cases = [ + ("", ""), + ("abc", "abc"), + ("abc", "axc"), + ("kitten", "sitting"), + ("repeat repeat", "repeat gap repeat"), + ("秘密", "秘X密"), + ]; + for (old, revised) in cases { + let old: Vec = old.chars().collect(); + let revised: Vec = revised.chars().collect(); + let script = myers_diff(&old, &revised).expect("small edit should align"); + let reconstructed: String = script + .iter() + .filter_map(|operation| match operation { + EditOp::Equal(_, revised_index) | EditOp::Insert(revised_index) => { + Some(revised[*revised_index]) + } + EditOp::Delete(_) => None, + }) + .collect(); + assert_eq!(reconstructed, revised.iter().collect::()); + } + } + + #[test] + fn normalization_handles_required_cleanup_shapes() { + let pairs = [ + ("e\u{301}", "é"), + ("file", "file"), + ("soft\u{ad}hyphen", "softhyphen"), + ("inter-\nnal", "internal"), + ("a b", "a b"), + ]; + for (old, revised) in pairs { + let old_tokens = canonical_tokens(old, &graphemes(old)); + let revised_tokens = canonical_tokens(revised, &graphemes(revised)); + assert_eq!( + old_tokens.iter().map(|token| token.ch).collect::(), + revised_tokens + .iter() + .map(|token| token.ch) + .collect::() + ); + } + } + + #[test] + fn duplicate_and_ordered_spans_have_identical_records() { + let (page, _, _) = generated_case(7); + let (output, audit) = process_page(page.clone(), 1); + let mut variant = page; + variant.spans.extend(variant.spans.clone()); + variant.spans.reverse(); + let (variant_output, variant_audit) = process_page(variant, 1); + assert_eq!(output, variant_output); + assert_eq!(audit, variant_audit); + } + + #[test] + fn malformed_geometry_blocks_publication() { + let (mut page, _, _) = generated_case(0); + page.glyphs.pop(); + let (output, audit) = process_page(page, 1); + assert_eq!(output.status, "blocked"); + assert_eq!(output.error_code.as_deref(), Some("missing_geometry")); + assert!(output.rectangles.is_empty()); + assert_eq!(audit.status, "blocked"); + } + + #[test] + fn changed_first_repeated_occurrence_is_never_mapped_only_to_the_second() { + let secret = "AMBIGUOUS_SECRET_X"; + let old = format!("{secret} gap {secret}"); + let revised = format!("AMBIGUOUS_SECR3T_X gap {secret}"); + let page = page_with_span("repeat-shift", &old, &revised, 0, secret.len()); + let (output, audit) = process_page(page, 1); + + assert_ne!(output.status, "blocked"); + assert_eq!(audit.status, "conservative"); + assert!( + output + .rectangles + .iter() + .any(|rectangle| rectangle.x < i32::try_from(secret.len() * 10).unwrap()), + "changed first occurrence must be covered: {:?}", + output.rectangles + ); + } + + #[test] + fn completed_resume_binds_input_output_and_audit() { + let dir = temp_test_dir("completed"); + let input = dir.join("pages.jsonl"); + let output = dir.join("output.jsonl"); + let audit = dir.join("audit.jsonl"); + let page = generated_case(0).0; + write_test_pages(&input, std::slice::from_ref(&page)); + run_remap(&remap_args(&input, &output, &audit, false, None)).expect("complete run"); + + write_test_pages(&input, &[page.clone(), page.clone()]); + let changed_input = run_remap(&remap_args(&input, &output, &audit, true, None)) + .expect_err("changed completed input must fail"); + assert_eq!(changed_input.code, "completed_state_mismatch"); + + write_test_pages(&input, std::slice::from_ref(&page)); + fs::write(&output, b"").expect("truncate completed output"); + let truncated_output = run_remap(&remap_args(&input, &output, &audit, true, None)) + .expect_err("truncated completed output must fail"); + assert_eq!(truncated_output.code, "completed_state_mismatch"); + + run_remap(&remap_args(&input, &output, &audit, false, None)) + .expect("restore completed run"); + let other_audit = dir.join("other-audit.jsonl"); + fs::copy(&audit, &other_audit).expect("copy audit"); + let changed_audit = run_remap(&remap_args(&input, &output, &other_audit, true, None)) + .expect_err("changed audit path must fail"); + assert_eq!(changed_audit.code, "completed_state_mismatch"); + } + + #[test] + fn stale_or_short_partial_state_never_completes_corrupted_output() { + let dir = temp_test_dir("stale"); + let input = dir.join("pages.jsonl"); + let invalid = dir.join("invalid.jsonl"); + let output = dir.join("output.jsonl"); + let audit = dir.join("audit.jsonl"); + let pages = [generated_case(0).0, generated_case(1).0]; + write_test_pages(&input, &pages); + fs::write(&invalid, b"{\"page_id\":\"").expect("write invalid UTF-8 prefix"); + let mut invalid_bytes = fs::read(&invalid).expect("read invalid prefix"); + invalid_bytes.push(0xff); + fs::write(&invalid, invalid_bytes).expect("write invalid UTF-8"); + + let interrupted = run_remap(&remap_args(&input, &output, &audit, false, Some(1))) + .expect("controlled interruption"); + assert!(matches!(interrupted, CommandResult::Interrupted(_))); + let invalid_run = run_remap(&remap_args(&invalid, &output, &audit, false, None)) + .expect_err("invalid fresh run must fail"); + assert_eq!(invalid_run.code, "invalid_utf8"); + assert!(!suffixed(&output, ".checkpoint").exists()); + + let restarted = run_remap(&remap_args(&input, &output, &audit, true, None)) + .expect("resume without a checkpoint safely restarts"); + assert!(matches!(restarted, CommandResult::Complete(_))); + let bytes = fs::read(&output).expect("read restarted output"); + assert!(!bytes.starts_with(&[0])); + assert_eq!(bytes.iter().filter(|byte| **byte == b'\n').count(), 2); + assert!(suffixed(&output, ".complete").exists()); + + run_remap(&remap_args(&input, &output, &audit, false, Some(1))) + .expect("second controlled interruption"); + fs::write(suffixed(&output, ".partial"), b"").expect("truncate partial output"); + let short = run_remap(&remap_args(&input, &output, &audit, true, None)) + .expect_err("short partial must fail"); + assert_eq!(short.code, "partial_short"); + assert!(!suffixed(&output, ".complete").exists()); + } + + fn page_with_span( + page_id: &str, + old_text: &str, + revised_text: &str, + start: usize, + end: usize, + ) -> PageInput { + let glyphs = graphemes(revised_text) + .into_iter() + .enumerate() + .map(|(index, grapheme)| { + Glyph( + grapheme.start, + grapheme.end, + 0, + i32::try_from(index * 10).unwrap(), + 0, + 10, + 10, + ) + }) + .collect(); + PageInput { + page_id: page_id.to_string(), + old_text: old_text.to_string(), + revised_text: revised_text.to_string(), + glyphs, + spans: vec![ReviewedSpan { + start, + end, + reason: "PERSON_ID".to_string(), + }], + } + } + + fn temp_test_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "ocr-redaction-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("create test directory"); + dir + } + + fn write_test_pages(path: &Path, pages: &[PageInput]) { + let mut bytes = Vec::new(); + for page in pages { + serde_json::to_writer(&mut bytes, page).expect("serialize test page"); + bytes.push(b'\n'); + } + fs::write(path, bytes).expect("write test pages"); + } + + fn remap_args( + input: &Path, + output: &Path, + audit: &Path, + resume: bool, + stop_after: Option, + ) -> RemapArgs { + RemapArgs { + input: input.to_path_buf(), + output: output.to_path_buf(), + audit: audit.to_path_buf(), + resume, + stop_after, + } + } +} diff --git a/developer-simulation/runs/2026-08-04--ocr-redaction-remap/src/main.rs b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/src/main.rs new file mode 100644 index 0000000..a1fd71c --- /dev/null +++ b/developer-simulation/runs/2026-08-04--ocr-redaction-remap/src/main.rs @@ -0,0 +1,5 @@ +use std::process::ExitCode; + +fn main() -> ExitCode { + ocr_redaction_remap::entry(std::env::args().skip(1).collect()) +} diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/.gitignore b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/Cargo.toml b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/Cargo.toml new file mode 100644 index 0000000..3b9ecbd --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "fastq-barcode-spill" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/README.md b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/README.md new file mode 100644 index 0000000..39c92e8 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/README.md @@ -0,0 +1,92 @@ +# FASTQ barcode spill trial + +This is a dependency-free Rust prototype for demultiplexing interleaved paired-end FASTQ from +standard input. It exact-matches the first 10 read-1 bases, corrects only a unique barcode at +Hamming distance one, sends ties to `ambiguous.fastq`, and sends other misses to +`unmatched.fastq`. Per-sample order is preserved. + +It creates all sample files but keeps at most 24 FASTQ output writers open. Small per-destination +buffers avoid reopening a file for every pair when hundreds of samples are interleaved. A +deterministic `manifest.json` is atomically published only after all input and output validation +succeeds. + +## Run the focused verification + +From the BogKit repository root: + +```console +export CARGO_TARGET_DIR=/private/tmp/fastq-barcode-spill-target +developer-simulation/runs/2026-08-05--fastq-barcode-spill/scripts/verify.sh +``` + +## Run the demo directly + +```console +export CARGO_TARGET_DIR=/private/tmp/fastq-barcode-spill-target +cargo build --release --offline --locked --manifest-path developer-simulation/Cargo.toml \ + -p fastq-barcode-spill +demo_dir="$(mktemp -d /private/tmp/fastq-barcode-spill-demo-XXXXXX)" +CARGO_TARGET_DIR=/private/tmp/fastq-barcode-spill-target +/private/tmp/fastq-barcode-spill-target/release/fastq-barcode-spill \ + --barcodes fixtures/barcodes.tsv \ + --out "$demo_dir/output" \ + < fixtures/mixed.fastq +python3 -m json.tool "$demo_dir/output/manifest.json" +``` + +The TSV has exactly `10-base-barcodesafe-sample-name` per non-comment line. The output +directory must be new or empty. Existing data is never overwritten. Sample output names must be +unique under ASCII case folding and may not alias the reserved `ambiguous` or `unmatched` names; +this is checked before the output directory is created. + +The supported paired identifiers are a non-empty printable first token with matching cores and +either `/1` plus `/2`, matching Illumina/CASAVA whitespace roles, or no explicit role. Conflicting +dual conventions and control bytes are rejected without echoing identifier content. + +## Reproduce the clean Python-baseline comparison + +```console +comparison_dir="$(mktemp -d /private/tmp/fastq-comparison-XXXXXX)" +trial=developer-simulation/runs/2026-08-05--fastq-barcode-spill +python3 "$trial/baseline.py" --barcodes "$trial/fixtures/barcodes.tsv" \ + --out "$comparison_dir/baseline" < "$trial/fixtures/clean.fastq" +/private/tmp/fastq-barcode-spill-target/release/fastq-barcode-spill \ + --barcodes "$trial/fixtures/barcodes.tsv" --out "$comparison_dir/rust" \ + < "$trial/fixtures/clean.fastq" +for f in alpha.fastq beta.fastq gamma.fastq delta.fastq unmatched.fastq; do + cmp "$comparison_dir/baseline/$f" "$comparison_dir/rust/$f" +done +``` + +The baseline intentionally exact-matches only and opens all sample destinations. The comparison +therefore covers clean per-sample and unmatched FASTQ bytes, not the Rust-only ambiguity output +or completion manifest. + +## Generate a numeric workload + +This preserves non-seekable stdin by piping the generated data directly: + +```console +trial=developer-simulation/runs/2026-08-05--fastq-barcode-spill +python3 "$trial/fixtures/generate.py" --samples 384 --pairs 0 --well-spaced \ + --barcodes /private/tmp/fastq-384.tsv +workload_dir="$(mktemp -d /private/tmp/fastq-million-XXXXXX)" +python3 "$trial/fixtures/generate.py" --samples 384 --pairs 1000000 --well-spaced --mixed --emit-only \ + --barcodes /private/tmp/fastq-384.tsv | + /usr/bin/time -l /private/tmp/fastq-barcode-spill-target/release/fastq-barcode-spill \ + --barcodes /private/tmp/fastq-384.tsv --out "$workload_dir/output" +``` + +Use a new/empty output directory for every run. `TRIAL_REPORT.md` records the measurements made +on the test host and does not generalize them to other hosts or record sizes. + +To independently observe the output descriptors with `lsof` during a throttled 200,000-pair +pipe (after the release build): + +```console +FASTQ_BINARY=/private/tmp/fastq-barcode-spill-target/release/fastq-barcode-spill \ + python3 "$trial/scripts/measure_open_files.py" +``` + +The observer exits nonzero if `lsof` produces no successful positive sample; its result is +supporting evidence for the implementation's structural 24-writer bound. diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/TRIAL_REPORT.md b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/TRIAL_REPORT.md new file mode 100644 index 0000000..70363ff --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/TRIAL_REPORT.md @@ -0,0 +1,362 @@ +# Trial report: fastq-barcode-spill + +Run date: 2026-08-05 (America/New_York) + +Sanitized checkout: `/private/tmp/bogkit-2026-08-05-fastq-CjPkDm` + +Starting commit: `80fd3c9a023e877fff2e5d127accca386d437af0` + +Trial directory: `trial-fastq-barcode-spill` + +## Outcome + +The reviewed prototype is a standalone, dependency-free Rust CLI. No BogKit component +fits this workload. Fold maintains durable views for later reads; ANNy and ESE are approximate +search and embedding tools. This problem is instead a bounded, one-pass byte router with no +database read path. Using any of those components would add persistence and state without +solving parsing, Hamming classification, or file-descriptor pressure. + +The prototype meets the requested acceptance checks on this test host for the supplied and +generated record shape. The final corrected run streamed 1,000,000 pairs through a pipe +across 384 sample destinations with an even four-way mix of exact matches, unique one-base +corrections, ambiguous ties, and unmatched reads. It completed in 1.93 seconds, used 63,750,144 +bytes maximum resident memory (60.8 MiB), and kept at most 24 FASTQ output files open. Two full +runs produced the same output-tree checksum. + +The timing and memory result is host- and fixture-specific. The parser currently has no maximum +FASTQ line length, so the 128 MiB claim is not extended to adversarially huge individual records. + +## What was built + +- `src/main.rs`: validated eight-line paired FASTQ reader for the documented identifier subset, + absolute line-number errors, paired-ID validation, sequence/quality length validation, + fixed-length hash-map lookup for exact and unique-Hamming-1 + lookup, bounded LRU append writers, per-destination buffers, counters, and final manifest. +- `baseline.py`: a minimal model of the stated existing Python behavior: exact matching and one + simultaneously open handle per sample. +- `fixtures/`: small clean, mixed, truncated, unequal-length, and mismatched-ID fixtures, plus a + deterministic workload generator. +- `tests/integration.py`: baseline comparison, seeded mutation, tie, unmatched, malformed, + privacy, writer-bound, count, and repeat-checksum checks. +- `scripts/measure_open_files.py`: an independent `lsof` observer for the open-output bound. +- `scripts/verify.sh`: the focused local verification and demo. + +All implementation and evidence instructions are confined to this trial directory. No BogKit +core file or public example was changed. No dependency download, GitHub use, commit, or push was +performed. + +## Acceptance result + +| Requirement | Result | Evidence | +| --- | --- | --- | +| Clean output byte-identical to baseline | Pass | All four sample files and `unmatched.fastq` compared equal byte-for-byte; hashes recorded below. | +| Seeded one-substitution correct | Pass | Seed `20260805` produced a one-base mutation routed to the expected sample; integration test also verifies output bytes. | +| Hamming-distance-one ties ambiguous | Pass | `GAAAAAAAAA` is one base from each of two whitelist entries and is written to `ambiguous.fastq`. | +| Counts cover complete pairs | Pass | Mixed million-pair manifest classifications sum to 1,000,000; sample counts sum to the 500,000 exact plus corrected pairs. | +| Truncation error, no manifest | Pass | Exit 1, `line 8`, and no `manifest.json`. | +| Unequal sequence/quality error, no manifest | Pass | Exit 1, `line 4`, and no `manifest.json`. | +| Mismatched pair IDs error, no manifest | Pass | Exit 1, `line 5`, and no `manifest.json`. | +| At most 24 sample files open | Pass | Internal maximum 24; corrected `lsof` sampling observed 24 across 45 successful polls. | +| 1M pairs, 384 samples, under 30s | Pass on this host | Final corrected run: 1.93 seconds real time, streamed through a pipe. | +| Under 128 MiB | Pass on this host and fixture | Final corrected run: 63,750,144-byte maximum resident set (60.8 MiB). | +| Repeated output and manifest checksums identical | Pass | Both corrected 387-file output trees hashed to `88e0f8be6e3f5622b19d4cf530c1af20a2f69ce8ece18c3bf347a466e22cd644`. | +| No read/sample data in logs | Pass for exercised errors | Integration asserts that IDs, sequence, quality, and a sample name do not occur in malformed-run stderr. Normal stdout contains counts only. | +| Completion manifest only after validation | Pass | Malformed runs leave partial FASTQ outputs but no completion manifest; valid runs atomically rename the completed manifest after writer flush and count checks. | +| Output filename uniqueness | Pass after review fix | ASCII-case-folded sample aliases and `Ambiguous`/`Unmatched` reserved aliases fail before output creation. | +| Supported identifier validation | Pass after review fix | Empty/control-byte identifiers and contradictory slash/CASAVA roles fail; ordinary CASAVA and CRLF inputs pass. | + +## Ordered discovery and friction trail + +1. Read the public root `README.md`. Its normal path is `./scripts/new-project.sh`, which creates + an example crate and adds Fold, ANNy, ESE, and Serde dependencies. +2. Read public examples in the README's order: `starter`, `timeseries`, `chat`, then `search`. + `starter` shows durable count/bag views; `timeseries` keyed aggregates; `chat` a durable source + of truth with snapshot broadcasting; `search` BM25/HNSW indexes. None has a role in a + single-pass FASTQ byte router. +3. Read `scripts/new-project.sh`. It always creates under `examples/` with all three local BogKit + dependencies. The brief requires a unique top-level directory and says not to change public + examples, so the trial was created manually and opted out of the parent Cargo workspace. +4. Searched this sanitized checkout for `fastq`, `barcode`, `demultip`, `fixture generator`, and + `python cli`; there was no existing FASTQ baseline artifact. Built the smallest reference + baseline described by the brief before selecting a BogKit component. +5. Reproduced the trial-created baseline model's file-descriptor failure at 384 samples with a + 64-file process limit. This illustrates the brief's stated design but does not verify the + unavailable production baseline and is not a BogKit defect. +6. The first clean fixture accidentally ended with an extra blank line. The baseline treated it + as the start of a truncated pair. Removed the blank line and added exact line-count fixtures. + This was a trial-fixture defect caught before making comparison claims. +7. The first parser version reported record-relative lines for malformed pairs after the first. + Absolute-line tests exposed the risk; the parser now carries the pair's starting line into all + validations. This was a prototype defect fixed before final measurement. +8. A naive correction path scanned all whitelist entries for every non-exact barcode. It was + functionally correct but an unnecessary scaling risk. Replaced it with a precomputed map of + every A/C/G/T/N one-base neighbor, where collisions are marked ambiguous. +9. Raw bounded LRU writers would reopen a file on nearly every pair under round-robin sample + input. Added 64 KiB per-destination buffers before the 24-entry LRU; the final million-pair + mixed run needed 1,952 file-open events rather than one per pair while preserving order. +10. The first piped benchmark tried to create and consume the barcode map concurrently, so the + consumer exited before input. Added the generator's `--emit-only` mode and made map creation + an explicit prior command. This was benchmark-fixture friction, not a prototype result. +11. Sandboxed `/usr/bin/time -l` could report elapsed time but could not read the macOS kernel RSS + counter (`sysctl kern.clockrate: Operation not permitted`). Re-ran the same bounded command + with permission to read resource counters. This was test-environment friction. +12. Ran the mixed million-pair measurement twice, reconciled manifest counts, and hashed every + output filename and byte. The tree checksums matched. +13. Skeptical review reproduced all headline results but found that `Alpha`/`alpha` and + `Ambiguous`/`ambiguous.fastq` could alias on the host filesystem, allowing a complete manifest + to describe mixed output classes. The loader now rejects ASCII-case-folded filename + collisions before creating the output directory, with exact integration regressions. +14. Review also showed accepted empty/control-byte identifiers and contradictory slash/CASAVA + roles, plus an `lsof` observer that false-passed when every observation failed. The parser now + rejects those identifiers without echoing them, and the observer requires a successful, + positive sample. A forced-failure observer regression and the real 45-poll check both pass. +15. The corrected suite, real descriptor measurement, and two million-pair runs were rerun. The + final resource observation and repeat checksum are the values reported here. + +## Commands and observed results + +Commands below were run from `trial-fastq-barcode-spill` unless stated otherwise. + +### Focused Rust checks + +```console +$ cargo test +running 5 tests +test tests::exact_unique_one_error_and_tie_are_distinct ... ok +test tests::malformed_inputs_report_expected_line ... ok +test tests::pair_reader_preserves_original_bytes ... ok +test result: ok. 5 passed; 0 failed + +$ cargo fmt --check +# exit 0, no output + +$ cargo clippy --all-targets -- -D warnings +Finished `dev` profile ... + +$ cargo build --release +Finished `release` profile ... + +$ python3 tests/integration.py target/release/fastq-barcode-spill +integration checks passed +``` + +The integration command exercises the Python comparison, seeded correction, barcode-level +ambiguity (including same-sample barcode ties), unmatched routing, malformed content and +identifier cases, case-folded filename collisions, no-manifest behavior, privacy assertions, a +30-sample writer cap of 3, and repeat checksums. + +### Demo + +```console +$ target/release/fastq-barcode-spill --barcodes fixtures/barcodes.tsv \ + --out /private/tmp/fastq-spill-evidence-cUbEIn/demo-final \ + < fixtures/mixed.fastq +processed 4 read pairs: exact 1, corrected 1, ambiguous 1, unmatched 1; max open output writers 3 +``` + +The manifest reported `total_pairs: 4`, one pair in each classification, two pairs for the +`gamma` destination, `max_open_writers: 3`, and `complete: true`. + +### Baseline behavior and clean comparison + +The descriptor-limit reproduction was: + +```console +$ python3 fixtures/generate.py --samples 384 --pairs 0 \ + --barcodes /private/tmp/fastq-spill-evidence-cUbEIn/barcodes-384.tsv +$ ulimit -n 64 +$ python3 baseline.py \ + --barcodes /private/tmp/fastq-spill-evidence-cUbEIn/barcodes-384.tsv \ + --out /private/tmp/fastq-spill-evidence-cUbEIn/baseline-384 +error: could not open all output files +# exit 1 +``` + +Clean comparison commands: + +```console +$ python3 baseline.py --barcodes fixtures/barcodes.tsv \ + --out /private/tmp/fastq-spill-evidence-cUbEIn/baseline-clean-final \ + < fixtures/clean.fastq +$ target/release/fastq-barcode-spill --barcodes fixtures/barcodes.tsv \ + --out /private/tmp/fastq-spill-evidence-cUbEIn/rust-clean-final \ + < fixtures/clean.fastq +processed 2 read pairs: exact 2, corrected 0, ambiguous 0, unmatched 0; max open output writers 2 +``` + +| Compared file | Equal | SHA-256 | +| --- | --- | --- | +| `alpha.fastq` | yes | `c3c28d466dfecbddcf0e6abca023b5608bd21968ef055529e35a95eb88d3f98a` | +| `beta.fastq` | yes | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | +| `gamma.fastq` | yes | `f53385669a88200f5cff6b896154e366f64b8e0c714d2b35d59b2ab547363493` | +| `delta.fastq` | yes | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | +| `unmatched.fastq` | yes | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | + +The baseline has no ambiguity output or completion manifest, so those Rust-only artifacts are +not part of the clean byte comparison. + +### Malformed input + +```console +$ target/release/fastq-barcode-spill --barcodes fixtures/barcodes.tsv \ + --out /private/tmp/fastq-spill-evidence-cUbEIn/bad-truncated \ + < fixtures/truncated.fastq +error: line 8: truncated interleaved FASTQ pair +# exit 1 + +$ target/release/fastq-barcode-spill --barcodes fixtures/barcodes.tsv \ + --out /private/tmp/fastq-spill-evidence-cUbEIn/bad-unequal \ + < fixtures/unequal.fastq +error: line 4: sequence and quality lengths differ +# exit 1 + +$ target/release/fastq-barcode-spill --barcodes fixtures/barcodes.tsv \ + --out /private/tmp/fastq-spill-evidence-cUbEIn/bad-mismatched \ + < fixtures/mismatched.fastq +error: line 5: paired read identifiers do not match +# exit 1 + +$ find /private/tmp/fastq-spill-evidence-cUbEIn/bad-truncated \ + /private/tmp/fastq-spill-evidence-cUbEIn/bad-unequal \ + /private/tmp/fastq-spill-evidence-cUbEIn/bad-mismatched \ + -name manifest.json -print +# no output +``` + +### Independent output-handle observation + +```console +$ python3 scripts/measure_open_files.py +observed_max_open_fastq_files=24 manifest_max_open_writers=24 polls=45 successful_polls=45 +``` + +This used a throttled 200,000-pair non-seekable pipe and counted only `.fastq` descriptors under +that run's output directory. The code also structurally refuses a `--max-open` value above 24. + +### Full numeric workload + +The generated whitelist has 384 entries. Two entries are Hamming distance two to provide a known +tie midpoint; every other entry is at least distance three from all others. The mixed input cycles +exact, unique one-substitution, tie, and unmatched cases. + +```console +$ python3 fixtures/generate.py --samples 384 --pairs 0 --well-spaced \ + --barcodes /private/tmp/fastq-spill-evidence-cUbEIn/mixed-barcodes.tsv + +$ python3 fixtures/generate.py --samples 384 --pairs 1000000 --well-spaced \ + --mixed --emit-only \ + --barcodes /private/tmp/fastq-spill-evidence-cUbEIn/mixed-barcodes.tsv | + /usr/bin/time -l target/release/fastq-barcode-spill \ + --barcodes /private/tmp/fastq-spill-evidence-cUbEIn/mixed-barcodes.tsv \ + --out /private/tmp/fastq-spill-evidence-cUbEIn/million-mixed-a +processed 1000000 read pairs: exact 250000, corrected 250000, ambiguous 250000, unmatched 250000; max open output writers 24 + 1.93 real 0.44 user 0.10 sys + 63750144 maximum resident set size +``` + +`/usr/bin/time` wrapped only the Rust consumer. Its real time includes any wait for the separate +Python producer, so the 1.93-second result is conservative for this generated pipe. The output +tree occupied 198,692 KiB on disk. + +The same generator and CLI command was repeated to `million-mixed-b`. A streaming SHA-256 over +each sorted filename and file contents produced: + +```text +million-mixed-a: 387 files, 88e0f8be6e3f5622b19d4cf530c1af20a2f69ce8ece18c3bf347a466e22cd644 +million-mixed-b: 387 files, 88e0f8be6e3f5622b19d4cf530c1af20a2f69ce8ece18c3bf347a466e22cd644 +``` + +Each manifest reported 1,000,000 classified pairs, 500,000 sample-routed pairs, 1,952 output-file +open events, and a maximum of 24 open writers. + +## Findings by source + +| Source | Finding | Severity | Confidence | Reproduction | Smallest improvement | +| --- | --- | --- | --- | --- | --- | +| Modeled Python baseline | The trial-created all-open model fails under a lower descriptor limit at 384 samples. This illustrates the stated design; it does not verify the unavailable production artifact. | High | High for the model | Set `ulimit -n 64` and launch the local model with the 384-sample map. | Use a bounded writer pool; buffering destinations avoids reopen-per-record cost. | +| Modeled Python baseline | The trial-created exact-only model sends a one-substitution sequencing error to unmatched. | High | High for the model | The seeded integration pair differs by one base from exactly one whitelist barcode. | Precompute exact and unique one-error lookup tables. | +| BogKit fit | Fold, ANNy, and ESE do not address this streaming transformation. This is a valid no-fit, not a defect. | Informational | High | Compare the public examples' durable view/search paths with the no-read-path brief. | No component change. Keep the CLI standalone. | +| BogKit onboarding | `new-project.sh` always creates under `examples/` and adds all three local components, even when none fit. | Low | High | Read or run the script with a disposable name. It has one fixed template. | Document a standalone/no-component route or add opt-in dependency flags. | +| Trial prototype | The measured acceptance workload passes, including all four classification paths. | Informational | High | Run `scripts/verify.sh`, the workload command, and the open-file observer. | None for the one-day boundary. | +| Trial prototype | A single pathological FASTQ line can grow memory beyond the measured fixture envelope because line length is not capped. | Medium | High | Feed an extremely long line and observe allocation before validation. Not run because it would not alter the stated fixture result. | Add a bounded line reader and a documented maximum record length. | +| Trial prototype | Malformed input leaves partial FASTQ files, deliberately without a completion manifest. Consumers must use the manifest as the completion sentinel. | Low | High | Run any malformed fixture and inspect its output directory. | Document the sentinel contract; optionally clean failed run directories when the caller explicitly permits deletion. | +| Trial prototype | Output files are flushed before manifest publication but are not individually `fsync`ed, so power-loss durability is not proven. | Medium | High | Requires filesystem fault/power-loss injection; not run. | Sync each touched output and the output directory before publishing the manifest if crash durability is required. | +| Trial prototype | Sample names are deliberately limited to safe ASCII filename characters and 120 bytes. Truly arbitrary sample labels are not accepted. | Low | High | Put whitespace or a path separator in column two of the map. | Separate opaque sample labels from sanitized output IDs if such labels are required. | +| Trial prototype | Case-insensitive output aliases could mix samples or the reserved ambiguity stream while the manifest claimed separation. Fixed after review. | High | High | Use `Alpha` and `alpha`, or sample `Ambiguous`, on this host. | Reject ASCII-case-folded collisions before output creation; retain regressions. | +| Trial prototype | Empty/control-byte IDs and contradictory slash/CASAVA roles were accepted. Fixed after review. | Medium | High | Use the archived integration cases. | Validate the supported identifier token and reconcile both role conventions. | +| Evidence script | A failing `lsof` command produced zero observations but exited successfully. Fixed after review. | Medium | High | Set `LSOF_COMMAND=/usr/bin/false`; the corrected script exits nonzero. | Require at least one successful, positive observation. | +| Trial fixtures | Extra trailing blank line and map-creation race initially invalidated test commands; both were fixed before final evidence. | Informational | High | Described in the ordered trail; current integration and piped commands pass. | Retain exact fixtures and `--emit-only` separation. | +| Test environment | Sandboxed `time -l` could not read kernel RSS counters. This was not a product failure. | Informational | High | Run the first timing command without resource-counter permission on this host. | Grant read access to the timer counters or use another process RSS observer. | + +## Decision audit + +1. **No BogKit runtime dependency.** The deciding evidence was the public examples plus the + baseline's actual failure mode. No component provides FASTQ framing or bounded fan-out. +2. **Dependency-free Rust.** The standard library covers buffered stdin, file append, maps, JSON + emission, and atomic rename. This avoids downloads and keeps the trial reproducible. +3. **Precomputed correction index.** Each whitelist barcode contributes every one-base A/C/G/T/N + neighbor. First insert is unique; any second barcode marks the neighbor ambiguous, even if both + barcodes name the same sample. Exact lookup takes precedence. This directly encodes the + barcode-level tie rule. +4. **64 KiB destination buffers plus 24-entry LRU.** LRU alone bounds handles but performs poorly + for round-robin 384-sample data. Buffers reduce churn while using roughly 24 MiB at 384 + destinations, inside the measured memory budget. +5. **Sequential, single-threaded processing.** It naturally preserves within-sample order and was + already far under the timing target. Parallel classification would add ordering machinery. +6. **New-or-empty output directory.** Refusing existing contents prevents accidental append or + truncation and makes repeated checks unambiguous. Case-folded output aliases are rejected + before the directory is created. +7. **Completion manifest last.** All parser checks, writer flushes, and count invariants finish + before a temporary manifest is synced and renamed to `manifest.json`. +8. **Original bytes retained.** Lines include their input line endings in output buffers, while + validation uses newline-stripped slices. A CRLF unit test confirms preservation. + +## Rejected choices + +- **Fold as a spool or counter store:** rejected because it introduces durable state and a second + representation of data without eliminating output FASTQ files; an uncompressed input spool is + explicitly out of scope. +- **ANNy or ESE for matching:** rejected because ten-base Hamming distance is exact discrete + matching, not semantic or approximate vector search. +- **All sample files open:** reproduces the baseline failure. +- **LRU append with no destination buffering:** bounded but risks nearly one open/close per pair + for round-robin samples. +- **One uncompressed temporary input copy:** violates the single-pass constraint. +- **Compressed outputs or a shared container:** would change baseline bytes and is a non-goal. +- **Third-party argument, JSON, LRU, or FASTQ crates:** unnecessary for the one-day prototype and + could require uncached downloads. +- **Deleting partial outputs automatically on malformed input:** not required by acceptance and + is a consequential behavior; the manifest is the explicit success boundary instead. + +## Unresolved uncertainty and narrowed claims + +- The largest run was the required 1,000,000-pair numeric workload, not the maximum stated + 10,000,000-pair stream. No ten-million-pair time, disk, or RSS claim is made. +- The 128 MiB result applies to the generated 40-base read 1 and 40-base read 2 records. Without a + line-length cap, it does not cover adversarially large individual FASTQ records. +- Corrected `lsof` sampling can miss short transients, but its 45 successful polls and observed + maximum agree with the + writer-pool invariant: opening entry 25 always flushes and drops the least-recently-used entry + first. +- The maximum applies to FASTQ output files. Standard input, stdout/stderr, and short-lived map or + manifest descriptors are outside the "sample files open" requirement. +- The manifest's atomic rename and file flush behavior was tested normally, not under disk-full, + I/O-fault, process-kill, or power-loss injection. +- Optional FASTQ conventions beyond the exercised `/1` and `/2` and matching Illumina whitespace + role forms may need broader production fixtures. Conflicting dual roles are rejected. +- Filenames expose safe sample names by design, as the existing per-sample convention implies. + The program does not print them, but filesystem metadata and the manifest contain them. + +## Coordinator rerun + +The disposable `/private/tmp/fastq-spill-evidence-cUbEIn` outputs may not survive. The durable +reproduction entry points are in this trial directory: + +```console +./scripts/verify.sh +python3 scripts/measure_open_files.py +``` + +For the full numeric run, use the exact two generator commands and timed pipeline in the README. +On a sandboxed macOS runner, `/usr/bin/time -l` may need permission to read kernel resource +counters; the functional command itself does not. diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/baseline.py b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/baseline.py new file mode 100755 index 0000000..8aae7ca --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/baseline.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Small reference implementation of the stated existing exact-match behavior.""" + +import argparse +import pathlib +import sys + + +def load_map(path: pathlib.Path) -> dict[str, str]: + result: dict[str, str] = {} + for line in path.read_text(encoding="ascii").splitlines(): + if line and not line.startswith("#"): + barcode, sample = line.split("\t") + result[barcode.upper()] = sample + return result + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--barcodes", type=pathlib.Path, required=True) + parser.add_argument("--out", type=pathlib.Path, required=True) + args = parser.parse_args() + + mapping = load_map(args.barcodes) + args.out.mkdir(parents=True) + # This intentionally models the original limitation: every destination is open. + handles = {} + try: + for sample in dict.fromkeys(mapping.values()): + handles[sample] = (args.out / f"{sample}.fastq").open("wb") + handles["__unmatched__"] = (args.out / "unmatched.fastq").open("wb") + except OSError: + for handle in handles.values(): + handle.close() + print("error: could not open all output files", file=sys.stderr) + return 1 + try: + stream = sys.stdin.buffer + while True: + lines = [stream.readline() for _ in range(8)] + if not lines[0]: + break + if any(not line for line in lines): + raise ValueError("truncated FASTQ pair") + barcode = lines[1].rstrip(b"\r\n")[:10].decode("ascii").upper() + destination = mapping.get(barcode, "__unmatched__") + handles[destination].writelines(lines) + finally: + for handle in handles.values(): + handle.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/barcodes.tsv b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/barcodes.tsv new file mode 100644 index 0000000..5806b50 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/barcodes.tsv @@ -0,0 +1,4 @@ +AAAAAAAAAA alpha +CAAAAAAAAA beta +CCCCCCCCCC gamma +GGGGGGGGGG delta diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/clean.fastq b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/clean.fastq new file mode 100644 index 0000000..e1dc69b --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/clean.fastq @@ -0,0 +1,16 @@ +@clean-a/1 +AAAAAAAAAATGCA ++ +IIIIIIIIIIIIII +@clean-a/2 +TGCATGCA ++ +IIIIIIII +@clean-c/1 +CCCCCCCCCCACGT ++ +IIIIIIIIIIIIII +@clean-c/2 +ACGTACGT ++ +IIIIIIII diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/generate.py b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/generate.py new file mode 100755 index 0000000..c3c0759 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/generate.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Deterministic interleaved FASTQ and barcode-map generator.""" + +import argparse +import pathlib +import sys +import time + +BASES = "ACGT" + + +def barcode_for(index: int) -> str: + chars = ["A"] * 10 + for position in range(9, -1, -1): + chars[position] = BASES[index & 3] + index >>= 2 + return "".join(chars) + + +def barcode_set(samples: int, well_spaced: bool) -> list[str]: + if not well_spaced: + return [barcode_for(index) for index in range(samples)] + + selected = ["AAAAAAAAAA", "CCAAAAAAAA"][:samples] + if len(selected) == samples: + return selected + for candidate_index in range(4**10): + candidate = barcode_for(candidate_index) + if all(sum(a != b for a, b in zip(candidate, chosen)) >= 3 for chosen in selected): + selected.append(candidate) + if len(selected) == samples: + return selected + raise ValueError("could not construct requested well-spaced barcode set") + + +def write_map(path: pathlib.Path, barcodes: list[str]) -> None: + with path.open("w", encoding="ascii", newline="\n") as output: + for index, barcode in enumerate(barcodes): + output.write(f"{barcode}\ts{index:03d}\n") + + +def mutate_once(barcode: str, pair_index: int) -> str: + position = pair_index % len(barcode) + replacement = BASES[(BASES.index(barcode[position]) + 1) % len(BASES)] + return barcode[:position] + replacement + barcode[position + 1 :] + + +def emit(pairs: int, barcodes: list[str], pause_ms: float, mixed: bool) -> None: + output = sys.stdout + r2_sequence = "TGCATGCATGCATGCATGCATGCATGCATGCATGCATGCA" + r2_quality = "I" * len(r2_sequence) + chunk: list[str] = [] + for pair_index in range(pairs): + if not mixed or pair_index % 4 == 0: + barcode = barcodes[pair_index % len(barcodes)] + elif pair_index % 4 == 1: + sample_index = 2 + ((pair_index // 4) % (len(barcodes) - 2)) + barcode = mutate_once(barcodes[sample_index], pair_index) + elif pair_index % 4 == 2: + barcode = "CAAAAAAAAA" # one base from each of the first two whitelist entries + else: + barcode = "NNNNNNNNNN" # more than one mismatch from every whitelist entry + r1_sequence = barcode + "ACGTACGTACGTACGTACGTACGTACGTAC" + r1_quality = "I" * len(r1_sequence) + chunk.append( + f"@read{pair_index:09d}/1\n{r1_sequence}\n+\n{r1_quality}\n" + f"@read{pair_index:09d}/2\n{r2_sequence}\n+\n{r2_quality}\n" + ) + if len(chunk) == 4096: + output.write("".join(chunk)) + output.flush() + chunk.clear() + if pause_ms: + time.sleep(pause_ms / 1000) + if chunk: + output.write("".join(chunk)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--samples", type=int, default=12) + parser.add_argument("--pairs", type=int, default=0) + parser.add_argument("--barcodes", type=pathlib.Path, required=True) + parser.add_argument("--pause-ms", type=float, default=0) + parser.add_argument("--emit-only", action="store_true") + parser.add_argument("--well-spaced", action="store_true") + parser.add_argument("--mixed", action="store_true") + args = parser.parse_args() + if not 1 <= args.samples <= 4**10: + parser.error("samples must be between 1 and 1,048,576") + if args.pairs < 0: + parser.error("pairs cannot be negative") + if args.mixed and (not args.well_spaced or args.samples < 3): + parser.error("--mixed requires --well-spaced and at least 3 samples") + barcodes = barcode_set(args.samples, args.well_spaced) + if not args.emit_only: + write_map(args.barcodes, barcodes) + emit(args.pairs, barcodes, args.pause_ms, args.mixed) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/mismatched.fastq b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/mismatched.fastq new file mode 100644 index 0000000..969698f --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/mismatched.fastq @@ -0,0 +1,8 @@ +@left/1 +AAAAAAAAAA ++ +IIIIIIIIII +@right/2 +ACGT ++ +IIII diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/mixed.fastq b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/mixed.fastq new file mode 100644 index 0000000..9838413 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/mixed.fastq @@ -0,0 +1,32 @@ +@exact/1 +CCCCCCCCCCACGT ++ +IIIIIIIIIIIIII +@exact/2 +ACGTACGT ++ +IIIIIIII +@corrected/1 +TCCCCCCCCCACGT ++ +IIIIIIIIIIIIII +@corrected/2 +ACGTACGT ++ +IIIIIIII +@tie/1 +GAAAAAAAAAACGT ++ +IIIIIIIIIIIIII +@tie/2 +ACGTACGT ++ +IIIIIIII +@unmatched/1 +TTTTTTTTTTACGT ++ +IIIIIIIIIIIIII +@unmatched/2 +ACGTACGT ++ +IIIIIIII diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/truncated.fastq b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/truncated.fastq new file mode 100644 index 0000000..724a6dc --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/truncated.fastq @@ -0,0 +1,7 @@ +@short/1 +AAAAAAAAAA ++ +IIIIIIIIII +@short/2 +ACGT ++ diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/unequal.fastq b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/unequal.fastq new file mode 100644 index 0000000..2ee242a --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/fixtures/unequal.fastq @@ -0,0 +1,8 @@ +@bad/1 +AAAAAAAAAA ++ +III +@bad/2 +ACGT ++ +IIII diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/scripts/measure_open_files.py b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/scripts/measure_open_files.py new file mode 100755 index 0000000..b0af8e9 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/scripts/measure_open_files.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Observe the release process with lsof during a throttled streamed run.""" + +import json +import os +import pathlib +import subprocess +import sys +import tempfile +import time + + +def main() -> int: + root = pathlib.Path(__file__).resolve().parents[1] + default_binary = root.parents[1] / "target" / "release" / "fastq-barcode-spill" + binary = pathlib.Path(os.environ.get("FASTQ_BINARY", default_binary)).resolve() + generator = root / "fixtures" / "generate.py" + lsof_command = os.environ.get("LSOF_COMMAND", "lsof") + pair_count = int(os.environ.get("FASTQ_MEASURE_PAIRS", "200000")) + + with tempfile.TemporaryDirectory(prefix="fastq-open-files-", dir="/private/tmp") as temporary: + temp = pathlib.Path(temporary) + barcode_map = temp / "barcodes.tsv" + output = temp / "output" + subprocess.run( + [ + sys.executable, + generator, + "--samples", + "384", + "--pairs", + "0", + "--well-spaced", + "--barcodes", + barcode_map, + ], + check=True, + ) + producer = subprocess.Popen( + [ + sys.executable, + generator, + "--samples", + "384", + "--pairs", + str(pair_count), + "--pause-ms", + "20", + "--barcodes", + barcode_map, + "--well-spaced", + "--mixed", + "--emit-only", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + assert producer.stdout is not None + consumer = subprocess.Popen( + [binary, "--barcodes", barcode_map, "--out", output, "--max-open", "24"], + stdin=producer.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + producer.stdout.close() + + observed_max = 0 + polls = 0 + successful_polls = 0 + prefix = f"n{output}/" + while consumer.poll() is None: + observation = subprocess.run( + [lsof_command, "-a", "-p", str(consumer.pid), "-Fn"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + if observation.returncode == 0: + successful_polls += 1 + open_fastq = sum( + 1 + for line in observation.stdout.splitlines() + if line.startswith(prefix) and line.endswith(".fastq") + ) + observed_max = max(observed_max, open_fastq) + polls += 1 + time.sleep(0.01) + + consumer_stdout, consumer_stderr = consumer.communicate() + producer_stderr = producer.communicate()[1] + if producer.returncode != 0 or consumer.returncode != 0: + print("measurement workload failed", file=sys.stderr) + if producer_stderr: + print(producer_stderr.decode("utf-8", "replace"), file=sys.stderr) + if consumer_stderr: + print(consumer_stderr.decode("utf-8", "replace"), file=sys.stderr) + return 1 + + manifest = json.loads((output / "manifest.json").read_text()) + print( + f"observed_max_open_fastq_files={observed_max} " + f"manifest_max_open_writers={manifest['max_open_writers']} polls={polls} " + f"successful_polls={successful_polls}" + ) + if successful_polls == 0 or observed_max == 0: + print("no valid positive lsof observation", file=sys.stderr) + return 1 + if observed_max > 24 or manifest["max_open_writers"] > 24: + return 1 + if f"processed {pair_count} read pairs".encode() not in consumer_stdout: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/scripts/verify.sh b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/scripts/verify.sh new file mode 100755 index 0000000..8c9e58a --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/scripts/verify.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" +workspace_manifest="$(cd "$root/../.." && pwd)/Cargo.toml" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-/private/tmp/fastq-barcode-spill-target}" + +cargo test --offline --locked --manifest-path "$workspace_manifest" -p fastq-barcode-spill +cargo fmt --manifest-path "$workspace_manifest" -p fastq-barcode-spill -- --check +cargo clippy --offline --locked --manifest-path "$workspace_manifest" \ + -p fastq-barcode-spill --all-targets -- -D warnings +cargo build --release --offline --locked --manifest-path "$workspace_manifest" \ + -p fastq-barcode-spill +python3 tests/integration.py "$CARGO_TARGET_DIR/release/fastq-barcode-spill" + +if FASTQ_BINARY="$CARGO_TARGET_DIR/release/fastq-barcode-spill" \ + FASTQ_MEASURE_PAIRS=2000 LSOF_COMMAND=/usr/bin/false \ + python3 scripts/measure_open_files.py; then + echo "failed lsof observer was accepted" >&2 + exit 1 +fi + +demo_dir="$(mktemp -d "${TMPDIR:-/tmp}/fastq-barcode-spill-demo-XXXXXX")" +"$CARGO_TARGET_DIR/release/fastq-barcode-spill" \ + --barcodes fixtures/barcodes.tsv \ + --out "$demo_dir" \ + < fixtures/mixed.fastq +python3 -m json.tool "$demo_dir/manifest.json" diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/src/main.rs b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/src/main.rs new file mode 100644 index 0000000..abb688e --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/src/main.rs @@ -0,0 +1,820 @@ +use std::collections::{BTreeMap, HashMap}; +use std::env; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufRead, BufReader, BufWriter, Write}; +use std::path::{Path, PathBuf}; + +const BARCODE_LEN: usize = 10; +const BUFFER_LIMIT: usize = 64 * 1024; +const AMBIGUOUS_FILE: &str = "ambiguous.fastq"; +const UNMATCHED_FILE: &str = "unmatched.fastq"; +const MANIFEST_FILE: &str = "manifest.json"; + +fn main() { + match Config::parse().and_then(run) { + Ok(summary) => { + println!( + "processed {} read pairs: exact {}, corrected {}, ambiguous {}, unmatched {}; max open output writers {}", + summary.total_pairs, + summary.exact_pairs, + summary.corrected_pairs, + summary.ambiguous_pairs, + summary.unmatched_pairs, + summary.max_open_writers + ); + } + Err(message) => { + eprintln!("error: {message}"); + std::process::exit(1); + } + } +} + +#[derive(Debug)] +struct Config { + barcode_path: PathBuf, + output_dir: PathBuf, + max_open: usize, +} + +impl Config { + fn parse() -> Result { + let mut barcode_path = None; + let mut output_dir = None; + let mut max_open = 24_usize; + let mut args = env::args().skip(1); + + while let Some(arg) = args.next() { + match arg.as_str() { + "--barcodes" => { + barcode_path = Some(PathBuf::from( + args.next() + .ok_or_else(|| "--barcodes requires a path".to_string())?, + )); + } + "--out" => { + output_dir = Some(PathBuf::from( + args.next() + .ok_or_else(|| "--out requires a directory".to_string())?, + )); + } + "--max-open" => { + let value = args + .next() + .ok_or_else(|| "--max-open requires a number".to_string())?; + max_open = value + .parse::() + .map_err(|_| "--max-open must be a positive integer".to_string())?; + if max_open == 0 || max_open > 24 { + return Err("--max-open must be between 1 and 24".to_string()); + } + } + "-h" | "--help" => { + println!( + "usage: fastq-barcode-spill --barcodes MAP.tsv --out DIRECTORY [--max-open 24]\n\ + reads interleaved paired-end FASTQ from standard input" + ); + std::process::exit(0); + } + _ => return Err("unknown argument; use --help for usage".to_string()), + } + } + + Ok(Self { + barcode_path: barcode_path + .ok_or_else(|| "missing required --barcodes path".to_string())?, + output_dir: output_dir.ok_or_else(|| "missing required --out directory".to_string())?, + max_open, + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct Barcode([u8; BARCODE_LEN]); + +#[derive(Debug)] +struct BarcodeMap { + exact: HashMap, + corrections: HashMap, + samples: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Correction { + Unique(usize), + Ambiguous, +} + +impl BarcodeMap { + fn load(path: &Path) -> Result { + let file = File::open(path).map_err(|_| "could not open barcode map".to_string())?; + let reader = BufReader::new(file); + let mut barcodes = Vec::new(); + let mut exact = HashMap::new(); + let mut samples = Vec::new(); + let mut sample_indexes = HashMap::new(); + let mut folded_sample_names = HashMap::new(); + + for (offset, line_result) in reader.lines().enumerate() { + let line_number = offset + 1; + let line = line_result + .map_err(|_| format!("barcode map line {line_number}: could not read line"))?; + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut fields = line.split('\t'); + let barcode_text = fields.next().unwrap_or_default(); + let sample = fields.next().ok_or_else(|| { + format!("barcode map line {line_number}: expected barcode and sample") + })?; + if fields.next().is_some() || barcode_text.is_empty() || sample.is_empty() { + return Err(format!( + "barcode map line {line_number}: expected exactly two non-empty fields" + )); + } + + let barcode = parse_barcode(barcode_text.as_bytes()) + .map_err(|message| format!("barcode map line {line_number}: {message}"))?; + validate_sample_name(sample) + .map_err(|message| format!("barcode map line {line_number}: {message}"))?; + if exact.contains_key(&barcode) { + return Err(format!("barcode map line {line_number}: duplicate barcode")); + } + + let folded_sample = sample.to_ascii_lowercase(); + if let Some(existing) = folded_sample_names.get(&folded_sample) { + if existing != sample { + return Err(format!( + "barcode map line {line_number}: sample output name collides case-insensitively" + )); + } + } else { + folded_sample_names.insert(folded_sample, sample.to_string()); + } + + let sample_index = match sample_indexes.get(sample) { + Some(index) => *index, + None => { + let index = samples.len(); + samples.push(sample.to_string()); + sample_indexes.insert(sample.to_string(), index); + index + } + }; + exact.insert(barcode, sample_index); + barcodes.push((barcode, sample_index)); + } + + if barcodes.is_empty() { + return Err("barcode map contains no barcodes".to_string()); + } + + let corrections = build_correction_index(&barcodes); + Ok(Self { + exact, + corrections, + samples, + }) + } + + fn classify(&self, observed: &[u8]) -> Classification { + let Ok(barcode) = parse_observed_barcode(observed) else { + return Classification::Unmatched; + }; + if let Some(sample_index) = self.exact.get(&barcode) { + return Classification::Exact(*sample_index); + } + + match self.corrections.get(&barcode) { + Some(Correction::Unique(sample_index)) => Classification::Corrected(*sample_index), + Some(Correction::Ambiguous) => Classification::Ambiguous, + None => Classification::Unmatched, + } + } +} + +fn build_correction_index(barcodes: &[(Barcode, usize)]) -> HashMap { + let mut corrections = HashMap::with_capacity(barcodes.len() * BARCODE_LEN * 4); + for (barcode, sample_index) in barcodes { + for position in 0..BARCODE_LEN { + for replacement in b"ACGTN" { + if *replacement == barcode.0[position] { + continue; + } + let mut neighbor = barcode.0; + neighbor[position] = *replacement; + corrections + .entry(Barcode(neighbor)) + .and_modify(|entry| *entry = Correction::Ambiguous) + .or_insert(Correction::Unique(*sample_index)); + } + } + } + corrections +} + +fn parse_barcode(bytes: &[u8]) -> Result { + if bytes.len() != BARCODE_LEN { + return Err(format!("barcode must contain exactly {BARCODE_LEN} bases")); + } + let mut barcode = [0; BARCODE_LEN]; + for (index, base) in bytes.iter().copied().enumerate() { + let normalized = base.to_ascii_uppercase(); + if !matches!(normalized, b'A' | b'C' | b'G' | b'T') { + return Err("barcode contains a non-ACGT base".to_string()); + } + barcode[index] = normalized; + } + Ok(Barcode(barcode)) +} + +fn parse_observed_barcode(bytes: &[u8]) -> Result { + if bytes.len() < BARCODE_LEN { + return Err(()); + } + let mut barcode = [0; BARCODE_LEN]; + for (index, base) in bytes[..BARCODE_LEN].iter().copied().enumerate() { + let normalized = base.to_ascii_uppercase(); + if !matches!(normalized, b'A' | b'C' | b'G' | b'T' | b'N') { + return Err(()); + } + barcode[index] = normalized; + } + Ok(Barcode(barcode)) +} + +fn validate_sample_name(sample: &str) -> Result<(), String> { + if sample.len() > 120 { + return Err("sample name is too long".to_string()); + } + let folded = sample.to_ascii_lowercase(); + if sample == "." + || sample == ".." + || folded == "ambiguous" + || folded == "unmatched" + || !sample + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err("sample name is not a safe output name".to_string()); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Classification { + Exact(usize), + Corrected(usize), + Ambiguous, + Unmatched, +} + +#[derive(Debug)] +struct FastqPair { + lines: [Vec; 8], +} + +impl FastqPair { + fn write_to(&self, destination: &mut Vec) { + for line in &self.lines { + destination.extend_from_slice(line); + } + } + + fn read_one_barcode(&self) -> &[u8] { + line_content(&self.lines[1]) + } +} + +struct PairReader { + input: R, + line_number: usize, +} + +impl PairReader { + fn new(input: R) -> Self { + Self { + input, + line_number: 0, + } + } + + fn next_pair(&mut self) -> Result, String> { + let mut lines: [Vec; 8] = std::array::from_fn(|_| Vec::new()); + let first_line = self.line_number + 1; + if !self.read_line(&mut lines[0])? { + return Ok(None); + } + for line in &mut lines[1..] { + if !self.read_line(line)? { + return Err(format!( + "line {}: truncated interleaved FASTQ pair", + self.line_number + 1 + )); + } + } + validate_record(&lines[0..4], first_line)?; + validate_record(&lines[4..8], first_line + 4)?; + if line_content(&lines[1]).len() < BARCODE_LEN { + return Err(format!( + "line {}: read 1 is shorter than the barcode length", + first_line + 1 + )); + } + validate_pair_ids(&lines[0], &lines[4], first_line, first_line + 4)?; + Ok(Some(FastqPair { lines })) + } + + fn read_line(&mut self, destination: &mut Vec) -> Result { + self.line_number += 1; + match self.input.read_until(b'\n', destination) { + Ok(0) => { + self.line_number -= 1; + Ok(false) + } + Ok(_) => Ok(true), + Err(_) => Err(format!("line {}: could not read input", self.line_number)), + } + } +} + +fn line_content(line: &[u8]) -> &[u8] { + let without_lf = line.strip_suffix(b"\n").unwrap_or(line); + without_lf.strip_suffix(b"\r").unwrap_or(without_lf) +} + +fn validate_record(lines: &[Vec], first_line: usize) -> Result<(), String> { + let header = line_content(&lines[0]); + let sequence = line_content(&lines[1]); + let separator = line_content(&lines[2]); + let quality = line_content(&lines[3]); + + if header.first() != Some(&b'@') || header.len() == 1 { + return Err(format!( + "line {first_line}: FASTQ header must start with @ and contain an identifier" + )); + } + if sequence.is_empty() || sequence.iter().any(|byte| !byte.is_ascii_alphabetic()) { + return Err(format!( + "line {}: FASTQ sequence must contain only base letters", + first_line + 1 + )); + } + if separator.first() != Some(&b'+') { + return Err(format!( + "line {}: FASTQ separator must start with +", + first_line + 2 + )); + } + if !separator[1..].is_empty() { + let repeated = first_token(&separator[1..]); + if repeated != first_token(&header[1..]) { + return Err(format!( + "line {}: FASTQ separator identifier does not match header", + first_line + 2 + )); + } + } + if sequence.len() != quality.len() { + return Err(format!( + "line {}: sequence and quality lengths differ", + first_line + 3 + )); + } + if quality.iter().any(|byte| !(33..=126).contains(byte)) { + return Err(format!( + "line {}: FASTQ quality contains a non-printable value", + first_line + 3 + )); + } + Ok(()) +} + +fn first_token(value: &[u8]) -> &[u8] { + let end = value + .iter() + .position(|byte| byte.is_ascii_whitespace()) + .unwrap_or(value.len()); + &value[..end] +} + +fn read_id(header_line: &[u8]) -> Result<(&[u8], Option), ()> { + let header = line_content(header_line); + let without_at = &header[1..]; + let token = first_token(without_at); + if token.is_empty() || token.iter().any(|byte| !(33..=126).contains(byte)) { + return Err(()); + } + let (core, slash_role) = if token.ends_with(b"/1") { + (&token[..token.len() - 2], Some(1)) + } else if token.ends_with(b"/2") { + (&token[..token.len() - 2], Some(2)) + } else { + (token, None) + }; + if core.is_empty() { + return Err(()); + } + + let rest = &without_at[token.len()..]; + let second = first_token(rest.trim_ascii_start()); + let role = match second.first() { + Some(b'1') if second.get(1) == Some(&b':') => Some(1), + Some(b'2') if second.get(1) == Some(&b':') => Some(2), + _ => None, + }; + if slash_role.is_some() && role.is_some() && slash_role != role { + return Err(()); + } + Ok((core, slash_role.or(role))) +} + +fn validate_pair_ids( + read_one_header: &[u8], + read_two_header: &[u8], + read_one_line: usize, + read_two_line: usize, +) -> Result<(), String> { + let (read_one_id, read_one_role) = read_id(read_one_header) + .map_err(|_| format!("line {read_one_line}: unsupported FASTQ identifier"))?; + let (read_two_id, read_two_role) = read_id(read_two_header) + .map_err(|_| format!("line {read_two_line}: unsupported FASTQ identifier"))?; + if read_one_role.is_some_and(|role| role != 1) { + return Err(format!("line {read_one_line}: first record is not read 1")); + } + if read_two_role.is_some_and(|role| role != 2) { + return Err(format!("line {read_two_line}: second record is not read 2")); + } + if read_one_id != read_two_id { + return Err(format!( + "line {read_two_line}: paired read identifiers do not match" + )); + } + Ok(()) +} + +struct OpenWriter { + writer: BufWriter, + last_used: u64, +} + +struct WriterPool { + paths: Vec, + open: BTreeMap, + capacity: usize, + clock: u64, + max_observed: usize, + open_events: u64, +} + +impl WriterPool { + fn new(paths: Vec, capacity: usize) -> Self { + Self { + paths, + open: BTreeMap::new(), + capacity, + clock: 0, + max_observed: 0, + open_events: 0, + } + } + + fn write(&mut self, destination: usize, bytes: &[u8]) -> Result<(), String> { + self.clock += 1; + if !self.open.contains_key(&destination) { + self.open_writer(destination)?; + } + let open_writer = self + .open + .get_mut(&destination) + .ok_or_else(|| "internal writer-pool error".to_string())?; + open_writer.last_used = self.clock; + open_writer + .writer + .write_all(bytes) + .map_err(|_| "could not write an output file".to_string()) + } + + fn open_writer(&mut self, destination: usize) -> Result<(), String> { + if self.open.len() == self.capacity { + let evict = self + .open + .iter() + .min_by_key(|(_, writer)| writer.last_used) + .map(|(index, _)| *index) + .ok_or_else(|| "internal writer-pool error".to_string())?; + let mut writer = self + .open + .remove(&evict) + .ok_or_else(|| "internal writer-pool error".to_string())?; + writer + .writer + .flush() + .map_err(|_| "could not flush an output file".to_string())?; + } + + let file = OpenOptions::new() + .append(true) + .open(&self.paths[destination]) + .map_err(|_| "could not open an output file".to_string())?; + self.open.insert( + destination, + OpenWriter { + writer: BufWriter::new(file), + last_used: self.clock, + }, + ); + self.open_events += 1; + self.max_observed = self.max_observed.max(self.open.len()); + Ok(()) + } + + fn finish(mut self) -> Result<(usize, u64), String> { + for writer in self.open.values_mut() { + writer + .writer + .flush() + .map_err(|_| "could not flush an output file".to_string())?; + } + Ok((self.max_observed, self.open_events)) + } +} + +#[derive(Debug)] +struct Summary { + total_pairs: u64, + exact_pairs: u64, + corrected_pairs: u64, + ambiguous_pairs: u64, + unmatched_pairs: u64, + sample_pairs: Vec, + max_open_writers: usize, + open_events: u64, +} + +fn run(config: Config) -> Result { + let barcode_map = BarcodeMap::load(&config.barcode_path)?; + let paths = prepare_output_directory(&config.output_dir, &barcode_map.samples)?; + let ambiguous_index = barcode_map.samples.len(); + let unmatched_index = ambiguous_index + 1; + let mut buffers: Vec> = (0..paths.len()).map(|_| Vec::new()).collect(); + let mut writers = WriterPool::new(paths, config.max_open); + let stdin = io::stdin(); + let mut pairs = PairReader::new(stdin.lock()); + let mut summary = Summary { + total_pairs: 0, + exact_pairs: 0, + corrected_pairs: 0, + ambiguous_pairs: 0, + unmatched_pairs: 0, + sample_pairs: vec![0; barcode_map.samples.len()], + max_open_writers: 0, + open_events: 0, + }; + + while let Some(pair) = pairs.next_pair()? { + let destination = match barcode_map.classify(pair.read_one_barcode()) { + Classification::Exact(sample_index) => { + summary.exact_pairs += 1; + summary.sample_pairs[sample_index] += 1; + sample_index + } + Classification::Corrected(sample_index) => { + summary.corrected_pairs += 1; + summary.sample_pairs[sample_index] += 1; + sample_index + } + Classification::Ambiguous => { + summary.ambiguous_pairs += 1; + ambiguous_index + } + Classification::Unmatched => { + summary.unmatched_pairs += 1; + unmatched_index + } + }; + summary.total_pairs += 1; + pair.write_to(&mut buffers[destination]); + if buffers[destination].len() >= BUFFER_LIMIT { + writers.write(destination, &buffers[destination])?; + buffers[destination].clear(); + } + } + + for (destination, buffer) in buffers.iter().enumerate() { + if !buffer.is_empty() { + writers.write(destination, buffer)?; + } + } + let (max_observed, open_events) = writers.finish()?; + summary.max_open_writers = max_observed; + summary.open_events = open_events; + + let classified = summary.exact_pairs + + summary.corrected_pairs + + summary.ambiguous_pairs + + summary.unmatched_pairs; + if classified != summary.total_pairs { + return Err("internal count validation failed".to_string()); + } + if summary.sample_pairs.iter().sum::() != summary.exact_pairs + summary.corrected_pairs { + return Err("internal sample-count validation failed".to_string()); + } + + write_manifest(&config.output_dir, &barcode_map.samples, &summary)?; + Ok(summary) +} + +fn prepare_output_directory(output_dir: &Path, samples: &[String]) -> Result, String> { + if output_dir.exists() { + if !output_dir.is_dir() { + return Err("output path is not a directory".to_string()); + } + if output_dir + .read_dir() + .map_err(|_| "could not inspect output directory".to_string())? + .next() + .is_some() + { + return Err("output directory must be empty".to_string()); + } + } else { + fs::create_dir_all(output_dir) + .map_err(|_| "could not create output directory".to_string())?; + } + + let mut paths: Vec = samples + .iter() + .map(|sample| output_dir.join(format!("{sample}.fastq"))) + .collect(); + paths.push(output_dir.join(AMBIGUOUS_FILE)); + paths.push(output_dir.join(UNMATCHED_FILE)); + for path in &paths { + File::create(path).map_err(|_| "could not create an output file".to_string())?; + } + Ok(paths) +} + +fn write_manifest(output_dir: &Path, samples: &[String], summary: &Summary) -> Result<(), String> { + let temporary = output_dir.join(".manifest.json.tmp"); + let final_path = output_dir.join(MANIFEST_FILE); + let file = File::create(&temporary).map_err(|_| "could not create manifest".to_string())?; + let mut writer = BufWriter::new(file); + writeln!(writer, "{{").map_err(|_| "could not write manifest".to_string())?; + writeln!(writer, " \"complete\": true,") + .map_err(|_| "could not write manifest".to_string())?; + writeln!(writer, " \"total_pairs\": {},", summary.total_pairs) + .map_err(|_| "could not write manifest".to_string())?; + writeln!(writer, " \"exact_pairs\": {},", summary.exact_pairs) + .map_err(|_| "could not write manifest".to_string())?; + writeln!( + writer, + " \"corrected_pairs\": {},", + summary.corrected_pairs + ) + .map_err(|_| "could not write manifest".to_string())?; + writeln!( + writer, + " \"ambiguous_pairs\": {},", + summary.ambiguous_pairs + ) + .map_err(|_| "could not write manifest".to_string())?; + writeln!( + writer, + " \"unmatched_pairs\": {},", + summary.unmatched_pairs + ) + .map_err(|_| "could not write manifest".to_string())?; + writeln!( + writer, + " \"max_open_writers\": {},", + summary.max_open_writers + ) + .map_err(|_| "could not write manifest".to_string())?; + writeln!(writer, " \"open_events\": {},", summary.open_events) + .map_err(|_| "could not write manifest".to_string())?; + writeln!(writer, " \"samples\": [").map_err(|_| "could not write manifest".to_string())?; + for (index, (sample, count)) in samples.iter().zip(summary.sample_pairs.iter()).enumerate() { + let comma = if index + 1 == samples.len() { "" } else { "," }; + writeln!( + writer, + " {{\"sample\": \"{sample}\", \"file\": \"{sample}.fastq\", \"pairs\": {count}}}{comma}" + ) + .map_err(|_| "could not write manifest".to_string())?; + } + writeln!(writer, " ]").map_err(|_| "could not write manifest".to_string())?; + writeln!(writer, "}}").map_err(|_| "could not write manifest".to_string())?; + writer + .flush() + .map_err(|_| "could not flush manifest".to_string())?; + let file = writer + .into_inner() + .map_err(|_| "could not finish manifest".to_string())?; + file.sync_all() + .map_err(|_| "could not sync manifest".to_string())?; + fs::rename(&temporary, &final_path) + .map_err(|_| "could not publish completion manifest".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn map() -> BarcodeMap { + let barcodes = [("AAAAAAAAAA", 0), ("CAAAAAAAAA", 1), ("CCCCCCCCCC", 2)]; + let parsed = barcodes + .iter() + .map(|(barcode, sample_index)| { + (parse_barcode(barcode.as_bytes()).unwrap(), *sample_index) + }) + .collect::>(); + let exact = parsed.iter().copied().collect(); + BarcodeMap { + exact, + corrections: build_correction_index(&parsed), + samples: vec!["a".into(), "b".into(), "c".into()], + } + } + + #[test] + fn exact_unique_one_error_and_tie_are_distinct() { + let map = map(); + assert_eq!(map.classify(b"AAAAAAAAAATTT"), Classification::Exact(0)); + assert_eq!(map.classify(b"TCCCCCCCCCTTT"), Classification::Corrected(2)); + assert_eq!(map.classify(b"GAAAAAAAAATTT"), Classification::Ambiguous); + assert_eq!(map.classify(b"TTTTTTTTTTTTT"), Classification::Unmatched); + } + + #[test] + fn same_sample_barcode_tie_remains_ambiguous() { + let parsed = [ + (parse_barcode(b"AAAAAAAAAA").unwrap(), 0), + (parse_barcode(b"CAAAAAAAAA").unwrap(), 0), + ]; + let map = BarcodeMap { + exact: parsed.iter().copied().collect(), + corrections: build_correction_index(&parsed), + samples: vec!["sample".into()], + }; + assert_eq!(map.classify(b"GAAAAAAAAA"), Classification::Ambiguous); + } + + #[test] + fn pair_reader_preserves_original_bytes() { + let input = b"@x/1\r\nAAAAAAAAAATG\r\n+\r\nIIIIIIIIIIII\r\n@x/2\r\nACGT\r\n+\r\nIIII\r\n"; + let mut reader = PairReader::new(Cursor::new(input)); + let pair = reader.next_pair().unwrap().unwrap(); + let mut output = Vec::new(); + pair.write_to(&mut output); + assert_eq!(output, input); + assert!(reader.next_pair().unwrap().is_none()); + } + + #[test] + fn identifier_validation_rejects_empty_control_and_conflicting_roles() { + let cases: [&[u8]; 3] = [ + b"@ /1\nAAAAAAAAAA\n+\nIIIIIIIIII\n@ /2\nACGT\n+\nIIII\n", + b"@bad\0/1\nAAAAAAAAAA\n+\nIIIIIIIIII\n@bad\0/2\nACGT\n+\nIIII\n", + b"@x/1 2:N:0:1\nAAAAAAAAAA\n+\nIIIIIIIIII\n@x/2 2:N:0:1\nACGT\n+\nIIII\n", + ]; + for input in cases { + assert!(PairReader::new(Cursor::new(input)).next_pair().is_err()); + } + + let casava = b"@machine:1:flow:2:3:4:5 1:N:0:ACGT\nAAAAAAAAAA\n+\nIIIIIIIIII\n@machine:1:flow:2:3:4:5 2:N:0:ACGT\nACGT\n+\nIIII\n"; + assert!( + PairReader::new(Cursor::new(casava)) + .next_pair() + .unwrap() + .is_some() + ); + } + + #[test] + fn malformed_inputs_report_expected_line() { + let truncated = b"@x/1\nAAAAAAAAAA\n+\nIIIIIIIIII\n@x/2\nACGT\n+\n"; + let mut reader = PairReader::new(Cursor::new(truncated)); + assert_eq!( + reader.next_pair().unwrap_err(), + "line 8: truncated interleaved FASTQ pair" + ); + + let mismatch = b"@x/1\nAAAAAAAAAA\n+\nIIIIIIIIII\n@y/2\nACGT\n+\nIIII\n"; + let mut reader = PairReader::new(Cursor::new(mismatch)); + assert_eq!( + reader.next_pair().unwrap_err(), + "line 5: paired read identifiers do not match" + ); + + let first = b"@ok/1\nAAAAAAAAAA\n+\nIIIIIIIIII\n@ok/2\nACGT\n+\nIIII\n"; + let second = b"@left/1\nAAAAAAAAAA\n+\nIIIIIIIIII\n@right/2\nACGT\n+\nIIII\n"; + let joined = [first.as_slice(), second.as_slice()].concat(); + let mut reader = PairReader::new(Cursor::new(joined)); + assert!(reader.next_pair().unwrap().is_some()); + assert_eq!( + reader.next_pair().unwrap_err(), + "line 13: paired read identifiers do not match" + ); + } +} diff --git a/developer-simulation/runs/2026-08-05--fastq-barcode-spill/tests/integration.py b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/tests/integration.py new file mode 100755 index 0000000..fcaa118 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--fastq-barcode-spill/tests/integration.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""End-to-end acceptance checks without third-party Python packages.""" + +import hashlib +import json +import pathlib +import random +import subprocess +import sys +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BARCODES = ROOT / "fixtures" / "barcodes.tsv" + + +def run(binary: pathlib.Path, data: bytes, output: pathlib.Path, *, max_open: int = 24): + return run_with_map(binary, BARCODES, data, output, max_open=max_open) + + +def run_with_map( + binary: pathlib.Path, + barcode_map: pathlib.Path, + data: bytes, + output: pathlib.Path, + *, + max_open: int = 24, +): + return subprocess.run( + [ + binary, + "--barcodes", + barcode_map, + "--out", + output, + "--max-open", + str(max_open), + ], + input=data, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + +def checksums(directory: pathlib.Path) -> dict[str, str]: + return { + path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(directory.iterdir()) + if path.is_file() + } + + +def pair(name: str, r1: str, r2: str = "ACGT") -> bytes: + return ( + f"@{name}/1\n{r1}\n+\n{'I' * len(r1)}\n" + f"@{name}/2\n{r2}\n+\n{'I' * len(r2)}\n" + ).encode("ascii") + + +def main() -> int: + binary = pathlib.Path(sys.argv[1]).resolve() + clean = (ROOT / "fixtures" / "clean.fastq").read_bytes() + mixed = (ROOT / "fixtures" / "mixed.fastq").read_bytes() + + with tempfile.TemporaryDirectory(prefix="fastq-spill-test-") as temp_text: + temp = pathlib.Path(temp_text) + baseline_out = temp / "baseline" + baseline = subprocess.run( + [sys.executable, ROOT / "baseline.py", "--barcodes", BARCODES, "--out", baseline_out], + input=clean, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + assert baseline.returncode == 0, baseline.stderr + + rust_out = temp / "rust-clean" + result = run(binary, clean, rust_out) + assert result.returncode == 0, result.stderr + for filename in ["alpha.fastq", "beta.fastq", "gamma.fastq", "delta.fastq", "unmatched.fastq"]: + assert (rust_out / filename).read_bytes() == (baseline_out / filename).read_bytes(), filename + + mixed_out = temp / "mixed" + result = run(binary, mixed, mixed_out) + assert result.returncode == 0, result.stderr + manifest = json.loads((mixed_out / "manifest.json").read_text()) + assert manifest["total_pairs"] == 4 + assert manifest["exact_pairs"] == 1 + assert manifest["corrected_pairs"] == 1 + assert manifest["ambiguous_pairs"] == 1 + assert manifest["unmatched_pairs"] == 1 + assert sum(item["pairs"] for item in manifest["samples"]) == 2 + assert b"@corrected/1" in (mixed_out / "gamma.fastq").read_bytes() + assert b"@tie/1" in (mixed_out / "ambiguous.fastq").read_bytes() + + rng = random.Random(20260805) + mutated = list("CCCCCCCCCC") + position = rng.randrange(10) + mutated[position] = rng.choice([base for base in "ACGT" if base != mutated[position]]) + seeded_out = temp / "seeded-mutation" + seeded_data = pair("seeded", "".join(mutated) + "ACGT") + result = run(binary, seeded_data, seeded_out) + assert result.returncode == 0, result.stderr + seeded_manifest = json.loads((seeded_out / "manifest.json").read_text()) + assert seeded_manifest["corrected_pairs"] == 1 + assert (seeded_out / "gamma.fastq").read_bytes() == seeded_data + + malformed = { + "truncated": ( + (ROOT / "fixtures" / "truncated.fastq").read_bytes(), + b"line 8:", + ), + "length": ( + (ROOT / "fixtures" / "unequal.fastq").read_bytes(), + b"line 4:", + ), + "pair-id": ( + (ROOT / "fixtures" / "mismatched.fastq").read_bytes(), + b"line 5:", + ), + } + for name, (data, line_marker) in malformed.items(): + output = temp / f"bad-{name}" + result = run(binary, data, output) + assert result.returncode != 0, name + assert line_marker in result.stderr, (name, result.stderr) + assert not (output / "manifest.json").exists(), name + for secret in (b"left", b"right", b"AAAAAAAAAA", b"IIII", b"alpha"): + assert secret not in result.stderr, (name, secret, result.stderr) + + identifier_cases = { + "empty-id": b"@ /1\nAAAAAAAAAA\n+\nIIIIIIIIII\n@ /2\nACGT\n+\nIIII\n", + "control-id": b"@hidden\0/1\nAAAAAAAAAA\n+\nIIIIIIIIII\n@hidden\0/2\nACGT\n+\nIIII\n", + "conflicting-role": ( + b"@dual/1 2:N:0:1\nAAAAAAAAAA\n+\nIIIIIIIIII\n" + b"@dual/2 2:N:0:1\nACGT\n+\nIIII\n" + ), + } + for name, data in identifier_cases.items(): + output = temp / f"bad-{name}" + result = run(binary, data, output) + assert result.returncode != 0, name + assert b"unsupported FASTQ identifier" in result.stderr, (name, result.stderr) + assert not (output / "manifest.json").exists(), name + for secret in (b"hidden", b"dual", b"AAAAAAAAAA", b"IIII", b"alpha"): + assert secret not in result.stderr, (name, secret, result.stderr) + + collision_maps = { + "case": "AAAAAAAAAA\tAlpha\nCCCCCCCCCC\talpha\n", + "ambiguous": "AAAAAAAAAA\tAmbiguous\n", + "unmatched": "AAAAAAAAAA\tUnmatched\n", + } + for name, contents in collision_maps.items(): + barcode_map = temp / f"collision-{name}.tsv" + barcode_map.write_text(contents) + output = temp / f"collision-{name}" + result = run_with_map(binary, barcode_map, b"", output) + assert result.returncode != 0, name + assert not output.exists(), name + assert b"Alpha" not in result.stderr and b"Ambiguous" not in result.stderr, result.stderr + + first = temp / "repeat-a" + second = temp / "repeat-b" + assert run(binary, mixed, first, max_open=2).returncode == 0 + assert run(binary, mixed, second, max_open=2).returncode == 0 + assert checksums(first) == checksums(second) + + many_map = temp / "many.tsv" + generator = ROOT / "fixtures" / "generate.py" + subprocess.run( + [sys.executable, generator, "--samples", "30", "--pairs", "0", "--barcodes", many_map], + check=True, + ) + many_input = b"".join(pair(f"p{index}", _barcode(index)) for index in range(30)) + many_out = temp / "many" + result = subprocess.run( + [binary, "--barcodes", many_map, "--out", many_out, "--max-open", "3"], + input=many_input, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + assert result.returncode == 0, result.stderr + assert json.loads((many_out / "manifest.json").read_text())["max_open_writers"] == 3 + + print("integration checks passed") + return 0 + + +def _barcode(index: int) -> str: + bases = "ACGT" + chars = ["A"] * 10 + for position in range(9, -1, -1): + chars[position] = bases[index & 3] + index >>= 2 + return "".join(chars) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/developer-simulation/runs/2026-08-05--parcel-delta-tiles/Cargo.toml b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/Cargo.toml new file mode 100644 index 0000000..5b61c66 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "parcel-delta-tiles" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +serde = "1" +serde_json = "1.0.150" diff --git a/developer-simulation/runs/2026-08-05--parcel-delta-tiles/README.md b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/README.md new file mode 100644 index 0000000..dea261a --- /dev/null +++ b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/README.md @@ -0,0 +1,68 @@ +# Parcel delta tile planner trial + +This is a stateless Rust command that reads parcel edits as NDJSON and prints the +unique Web Mercator tiles at zooms 12 through 16 touched by an old or new filled +geometry. It plans only: it does not read parcel state or render, publish, or +delete tiles. + +No BogKit component is used. The root README and examples describe Fold as a +durable incremental-state engine. This input is already the authoritative delta, +and the command is forbidden from maintaining or consulting state, so Fold would +add persistence and retraction machinery without removing the geometry work. +ESE and ANNy do not address geometry. + +## Input and output + +Each nonblank input line has this shape: + +```json +{"id":"parcel-42","old":null,"new":{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}} +``` + +`id` is a non-empty string. `old` and `new` are optional or null, but at least +one must be a GeoJSON `Polygon` or `MultiPolygon`. Positions must contain exactly +longitude and latitude. Rings must contain at least four positions and be +closed. Longitudes must be in `[-180, 180]`; latitudes must be within Web +Mercator's limits. Duplicate JSON members are rejected recursively. Rings must +have nonzero area and no self-intersection; holes must be strictly contained and +non-overlapping; MultiPolygon exteriors may not overlap or nest. +Antimeridian-crossing geometry is explicitly rejected. + +Output is one `z/x/y` tile per line, sorted lexicographically by that rendered +tile ID. A tile counts when its closed rectangle intersects the +filled polygon, including boundary-only contact. A tile wholly inside a hole is +excluded; hole boundaries count as contact. + +The command validates the complete stream before writing any plan, so an error +names the input line on stderr and leaves stdout empty. + +## Reproduce + +Run from the BogKit repository root: + +```console +export CARGO_TARGET_DIR=/private/tmp/parcel-delta-tiles-target +trial=developer-simulation/runs/2026-08-05--parcel-delta-tiles +cargo test --offline --locked --manifest-path developer-simulation/Cargo.toml \ + -p parcel-delta-tiles +cargo fmt --manifest-path developer-simulation/Cargo.toml -p parcel-delta-tiles -- --check +cargo clippy --offline --locked --manifest-path developer-simulation/Cargo.toml \ + -p parcel-delta-tiles --all-targets -- -D warnings +cargo build --release --offline --locked --manifest-path developer-simulation/Cargo.toml \ + -p parcel-delta-tiles +/private/tmp/parcel-delta-tiles-target/release/parcel-delta-tiles "$trial/fixtures/demo.ndjson" +node "$trial/scripts/reference.ts" "$trial/fixtures/demo.ndjson" +node "$trial/scripts/verify.ts" /private/tmp/parcel-delta-tiles-target/release/parcel-delta-tiles +node "$trial/scripts/generate-workload.ts" /private/tmp/parcel-delta-tiles-2026-08-05-diverse.ndjson +/usr/bin/time -l /private/tmp/parcel-delta-tiles-target/release/parcel-delta-tiles \ + /private/tmp/parcel-delta-tiles-2026-08-05-diverse.ndjson > /dev/null +``` + +`scripts/reference.ts` deliberately scans every candidate tile across the +complete edited county extent and then scans edit geometries for that tile. It +is a slow full-scan enumeration mirror, not an independent geometry oracle or a +production algorithm: it shares the Rust planner's planar intersection design. +The verifier separately checks 500 axis-aligned rectangles against an analytical +tile-range construction. + +See `TRIAL_REPORT.md` for observed results and limitations. diff --git a/developer-simulation/runs/2026-08-05--parcel-delta-tiles/TRIAL_REPORT.md b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/TRIAL_REPORT.md new file mode 100644 index 0000000..87c9ecd --- /dev/null +++ b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/TRIAL_REPORT.md @@ -0,0 +1,401 @@ +# Trial report: `parcel-delta-tiles` + +Date: 2026-08-05 (America/New_York) + +Checkout: sanitized detached `80fd3c9a023e877fff2e5d127accca386d437af0` + +## Outcome + +The prototype is a successful, bounded implementation of the requested planner, +but the BogKit fit decision is **no fit**. Fold's value is durable incremental +state derived from inserts and retractions. This command is required to be a +stateless, plan-only transformation whose old and new geometries are already the +authoritative delta, and it must not read or write a parcel store. ESE and ANNy +solve embedding and nearest-neighbor problems, not polygon/tile intersection. + +The corrected Rust command streams NDJSON one line at a time, rejects duplicate +JSON members and invalid simple topology before emitting anything, enumerates +only geometry-bounding candidate tiles, tests +closed tile rectangles against polygon fill and hole boundaries, deduplicates, +and renders lexicographically sorted `z/x/y` IDs. It uses only `serde` and +`serde_json`; it does not depend on or change BogKit core. + +## Finishing criteria used + +- One runnable Rust NDJSON CLI in the required top-level directory. +- Old and new `Polygon`/`MultiPolygon` geometry handled without parcel state. +- Exact agreement with a runnable TypeScript full-scan mirror on the named cases + and a 10,000-edit seeded batch, plus an analytical 500-rectangle check that + does not share the polygon predicate. +- A tile wholly inside a hole excluded; boundary-only contact included. +- Ten input permutations produce byte-identical output. +- Malformed coordinate, open ring, nonfinite number, unsupported geometry, + duplicate JSON members, zero-area/self-intersecting rings, outside holes, and + overlapping holes fail deterministically with the input line and empty stdout. +- A 1,000-edit, 200-distinct-vertex workload stays below 5 seconds and 256 MiB. +- Unit tests, format check, lint with warnings denied, release build, demo, and + workload comparison pass. +- Only `trial-parcel-delta-tiles` is added; no public example or core file changes. + +All criteria passed for the supplied synthetic scope. + +## Ordered discovery and friction trail + +1. Read the root `README.md` first. It introduces Fold, ESE, ANNy, then lists + examples in this order: `starter`, `timeseries`, `chat`, `search`. +2. Read each listed example in that order, including its manifest and complete + public `main.rs`. + - `starter` showed persistent `Count`/`Bag` views and retraction. + - `timeseries` showed keyed aggregation maintained from mutations. + - `chat` showed Fold as durable source of truth. + - `search` showed keyed upsert/remove driving three maintained indexes. +3. Compared those public contracts with the stated baseline: the input already + carries authoritative old/new geometry, planning is one-shot, and state access + is prohibited. This ruled out Fold before implementation; ESE and ANNy were + plainly unrelated. +4. Inspected the public scaffold. `scripts/new-project.sh` creates under + `examples/` and unconditionally adds local Fold, ESE, and ANNy dependencies. + The task required a unique top-level directory and no example changes, so the + scaffold was not run. +5. No runnable TypeScript parcel baseline or parcel fixture existed in this + sanitized checkout. To make the stated reference behavior executable, added + `scripts/reference.ts`. It deliberately scans every candidate tile across the + complete edited extent, then scans old/new geometries for that tile. +6. Ran the TypeScript mirror on `fixtures/demo.ndjson` before compiling Rust. It + exited 0 and produced a deterministic plan. The final aligned demo contains + 72 tiles. +7. Implemented the Rust parser and intersection planner. The first normal + `cargo test` attempted to update crates.io and failed after three DNS retries. + This was restricted-network environment friction, not a source or BogKit + failure. `cargo test --offline` used cached dependencies and passed. +8. The first `cargo fmt --check` reported formatting diffs. Ran `cargo fmt`; all + subsequent format checks passed. No behavior changed. +9. The first sandboxed `/usr/bin/time -l` run completed the planner in 0.05 s, + but `time` itself exited 1 because sandboxed `sysctl kern.clockrate` was + denied. Re-running the exact command with host permission produced valid + timing and peak-RSS evidence. +10. Literal “lexicographically sorted” output was made explicit by sorting the + rendered `z/x/y` strings. A focused unit test covers differing digit widths. +11. The performance generator was corrected to use 200 distinct vertices plus + the required repeated ring closure (201 coordinate positions), rather than + counting the closure as a vertex. All final evidence below uses the corrected + 7,910,712-byte workload. +12. Skeptical review reproduced the declared checks but showed that duplicate + JSON members were last-member-wins accepted by both implementations and that + self-intersecting, zero-area, and invalid-hole geometry was silently planned. + Recursive strict JSON parsing and explicit simple-ring, containment, and + non-overlap validation now reject those inputs with no stdout. +13. Review also rejected calling the TypeScript implementation an independent + geometry oracle because it shares the same planar predicate and tolerance. + It is now labeled a mirror/enumeration cross-check. The verifier adds 500 + worldwide axis-aligned rectangles checked against an analytical tile-range + construction, plus exact corner and horizontal/vertical boundary cases. +14. The final workload was diversified across inserts, deletes, replacements, + concave polygons, holes, MultiPolygons, four extents, and a wider synthetic + county. It has 1,000 lines, 200 distinct vertices per line, 2,922 output + tiles, and is 7,977,879 bytes. The corrected Rust result matched the mirror, + then completed in 0.14 seconds with 2,670,592 bytes maximum RSS. The mirror + took 11.99 seconds and 169,312,256 bytes RSS on the same host-specific input. + +## Implementation boundary + +Durable files are all under this directory: + +- `src/lib.rs`: strict per-line parsing, simple-topology validation, Web Mercator conversion, candidate + enumeration, rectangle/polygon intersection, hole handling, set construction, + and output formatting. +- `src/main.rs`: stdin/file CLI and delayed output. +- `scripts/reference.ts`: slow TypeScript full-scan mirror with independent + enumeration but shared planar geometry rules. +- `scripts/verify.ts`: named mirror comparisons, 10,000 seeded edits, 500 + analytical rectangles, ten permutations, and deterministic malformed checks. +- `scripts/generate-workload.ts`: deterministic diverse 1,000 × 200-vertex workload. +- `fixtures/`: demo and four malformed cases. +- `README.md`: exact input contract and reproduction commands. + +Generated workload data is disposable at +`/private/tmp/parcel-delta-tiles-2026-08-05-diverse.ndjson`; it is not part of the trial. + +## Exact verification and observed results + +Commands were run from `trial-parcel-delta-tiles` unless noted. + +### Focused tests, formatting, lint, release + +```console +$ cargo test --offline +``` + +Exit 0. Eight unit tests passed, including exact edge and four-way corner +touches, a tile wholly inside a hole, recursive duplicate rejection, invalid +topology rejection, line-specific malformed input, and lexicographic ordering. + +```console +$ cargo fmt --check +``` + +Exit 0 after applying the formatter once. + +```console +$ cargo clippy --offline --all-targets -- -D warnings +``` + +Exit 0, no warnings. + +```console +$ cargo build --release --offline +``` + +Exit 0. Final incremental release build completed in 0.48 s. + +### Demo and baseline comparison + +```console +$ target/release/parcel-delta-tiles fixtures/demo.ndjson | wc -l + 72 +$ cmp <(target/release/parcel-delta-tiles fixtures/demo.ndjson) \ + <(node scripts/reference.ts fixtures/demo.ndjson) +``` + +`cmp` exited 0: the 72-line plans were byte-identical. + +### Mirror, analytical, permutation, and malformed cases + +```console +$ node scripts/verify.ts target/release/parcel-delta-tiles +``` + +Exit 0 with: + +```text +mirror insertion: exact (17 tiles) +mirror deletion: exact (17 tiles) +mirror translation: exact (19 tiles) +mirror concavity: exact (37 tiles) +mirror holes: exact (69 tiles) +mirror multipolygon: exact (8 tiles) +mirror boundary-touch: exact (6 tiles) +mirror seeded-10000: exact (11 tiles) +analytical rectangles: 500 edits exact (34650 tiles) +permutations: 10/10 byte-identical +malformed open-ring: deterministic line 2, empty stdout +malformed coordinate: deterministic line 1, empty stdout +malformed nonfinite: deterministic line 1, empty stdout +malformed unsupported-type: deterministic line 1, empty stdout +malformed duplicate-edit: deterministic line 2, empty stdout +malformed duplicate-geometry: deterministic line 1, empty stdout +malformed duplicate-nested: deterministic line 1, empty stdout +malformed self-intersection: deterministic line 1, empty stdout +malformed zero-area: deterministic line 1, empty stdout +malformed hole-outside: deterministic line 1, empty stdout +malformed hole-overlap: deterministic line 1, empty stdout +verification complete +``` + +Each malformed case was run twice by the verifier. Diagnostics identify the +input line and a static structural reason without partial stdout. The duplicate +edit regression deliberately places valid line 1 before invalid line 2. + +```text +input line 1: new.coordinates[0][1][0] longitude is outside [-180, 180] +input line 1: invalid JSON: number out of range at line 1 column 70 +input line 1: new.type "LineString" is unsupported +input line 2: invalid JSON: duplicate object member `new` ... +input line 1: new.coordinates[0] self-intersects +input line 1: new.coordinates[0] has zero area +input line 1: new.coordinates[1] must be strictly inside the exterior ring +input line 1: new.coordinates[1] and new.coordinates[2] overlap or nest +``` + +All eleven malformed cases had status 1 and zero stdout. + +### Full numeric workload and resource evidence + +```console +$ node scripts/generate-workload.ts /private/tmp/parcel-delta-tiles-2026-08-05-diverse.ndjson +wrote 1000 mixed-operation edits with 200 distinct vertices per line to /private/tmp/parcel-delta-tiles-2026-08-05-diverse.ndjson +$ wc -l -c /private/tmp/parcel-delta-tiles-2026-08-05-diverse.ndjson + 1000 7977879 /private/tmp/parcel-delta-tiles-2026-08-05-diverse.ndjson +``` + +The final generator mixes inserts, deletes, and replacements; concave polygons, +polygons with holes, and MultiPolygons; four feature extents; and varied locations +across a wider synthetic county. Every line contains exactly 200 distinct vertices +across its old/new geometry, plus required repeated closures. + +```console +$ cmp <(target/release/parcel-delta-tiles /private/tmp/parcel-delta-tiles-2026-08-05-diverse.ndjson) \ + <(node scripts/reference.ts /private/tmp/parcel-delta-tiles-2026-08-05-diverse.ndjson) +$ target/release/parcel-delta-tiles /private/tmp/parcel-delta-tiles-2026-08-05-diverse.ndjson | wc -l + 2922 +``` + +`cmp` exited 0: the full diverse 1,000 × 200 workload matched the mirror exactly. + +Final host-permitted measurement: + +```console +$ /usr/bin/time -l target/release/parcel-delta-tiles \ + /private/tmp/parcel-delta-tiles-2026-08-05-diverse.ndjson > /dev/null +``` + +The corrected diverse run observed 0.14 s real and 2,670,592 bytes maximum RSS. +This one-host input-specific result is far below 5 s and 256 MiB. + +For a bounded same-input comparison only: + +```console +$ /usr/bin/time -l node scripts/reference.ts \ + /private/tmp/parcel-delta-tiles-2026-08-05-diverse.ndjson > /dev/null +``` + +Observed 11.99 s real and 169,312,256 bytes maximum RSS. This is one run of the +trial-created mirror, not evidence for or against the brief's reported roughly +40-minute production baseline. + +## Acceptance audit + +| Requirement | Result | Evidence | +| --- | --- | --- | +| Insertion/deletion/replacement | Pass | named mirror cases and mixed final workload | +| Concavity | Pass | named case and mixed final workload | +| Holes | Pass | mirror case, wholly-inside-hole assertion, and mixed workload | +| Multipolygons | Pass | mirror case and mixed workload | +| Boundary touches | Pass | vertical edge, four-way corner, hole boundary, and reviewer probes | +| 10,000 seeded valid edits | Pass | byte-exact mirror comparison | +| 500 analytical rectangles | Pass | 34,650 tiles matched independent range construction | +| Ten permutations | Pass | 10/10 byte-identical | +| 1,000 × 200 under 5 s/256 MiB | Pass | diverse run: 0.14 s, 2,670,592-byte RSS | +| Malformed line/no partial plan | Pass | eleven deterministic cases, empty stdout | +| Plan only | Pass | CLI has no rendering, publishing, deletion, or parcel-store path | + +## Categorized findings + +### 1. No BogKit component fits the authoritative-delta planner + +- Category: fit decision, not a defect +- Severity: informational +- Confidence: high +- Reproduction: read the public examples, then compare their durable stream/view + contracts with the prohibited-state, authoritative-old/new brief. +- Smallest improvement: a short “when not to use Fold” paragraph in the root + README would help new Rust users reach this conclusion quickly. + +### 2. The prototype meets the bounded functional and numeric acceptance checks + +- Category: prototype result +- Severity: success +- Confidence: high for the generated and named fixtures; medium for arbitrary + production cadastral data +- Reproduction: `node scripts/verify.ts target/release/parcel-delta-tiles` and + the full-workload commands above. +- Smallest improvement: before production adoption, replay a captured county + corpus against the actual existing TypeScript reference. + +### 3. The stated runnable TypeScript baseline was absent from the sanitized checkout + +- Category: trial-input/baseline gap, not a BogKit defect +- Severity: medium because exact production equivalence cannot be established +- Confidence: high +- Reproduction: the initial public file inventory contained only BogKit root, + crates, scripts, and examples; no parcel baseline or county fixture. +- Smallest improvement: supply the baseline command, fixture/schema, expected + checksum, and host description with the brief. + +### 4. The public project generator installs all three local components + +- Category: BogKit onboarding friction, not a core correctness defect +- Severity: low +- Confidence: high +- Reproduction: inspect `scripts/new-project.sh`; it always adds `fold`, `ese`, + and `anny`, while the README notes they may not all be used. +- Smallest improvement: add a dependency-free/minimal mode or prompt for only + the components selected after fit evaluation. + +### 5. Duplicate members and invalid topology were false-accepted, fixed + +- Category: prototype correctness defects, not BogKit defects +- Severity: high +- Confidence: high +- Reproduction: review showed last-member-wins duplicate `new`, a bow-tie ring, + a zero-area ring, a hole outside its exterior, and overlapping holes all + produced plans. The corrected verifier preserves each minimal case. +- Smallest improvement completed: reject duplicate object members recursively; + reject zero-length/zero-area/self-intersecting rings, non-contained holes, + overlapping/nested holes, and overlapping/nested MultiPolygon exteriors. + +### 6. The TypeScript comparison is a mirror, not an independent geometry oracle + +- Category: evidence limitation +- Severity: medium for a production go/no-go decision +- Confidence: high +- Reproduction: Rust and TypeScript use different enumeration strategies but + share parsing rules, tolerance, clipping, point-in-ring, and hole logic. +- Smallest improvement completed: relabel it as an enumeration mirror and add a + 500-rectangle analytical range check. Production adoption still needs the real + county reference or a genuinely independent geometry engine. + +### 7. The normal Cargo path attempted network access despite cached dependencies + +- Category: environment/onboarding friction, not a BogKit defect +- Severity: low +- Confidence: high +- Reproduction: `cargo test` failed with `Could not resolve host: + index.crates.io`; `cargo test --offline` immediately resolved cached crates and + passed. +- Smallest improvement: document `--offline` for sanitized lab runs or pre-create + the trial lockfile before the first build. + +### 8. Evidence remains scoped to a planar, synthetic contract + +- Category: evidence limitation +- Severity: medium for a production go/no-go decision +- Confidence: high +- Reproduction: compare the local straight-longitude/latitude predicate and + synthetic workload with the absent production convention and county corpus. +- Smallest improvement: replay captured production data against the supplied + production reference and independently chosen geometry library. + +## Decision audit + +| Choice | Decision | Reason | +| --- | --- | --- | +| Fold | Reject | Requires durable mutation-derived state; this input is already the delta and state access is forbidden. | +| ESE | Reject | Embeddings do not contribute to spatial tile planning. | +| ANNy | Reject | Approximate nearest-neighbor indexing does not answer exact polygon/tile intersection. | +| Stateless Rust + strict `serde`/`serde_json` parsing | Select | Meets the bounded streaming, deterministic, and reviewed simple-topology contract. | +| External geometry crate | Reject for one-day prototype | Cached availability was unverified. The corrected simple-topology validator covers the named boundary, but a proven library should be reconsidered for production validity. | +| Emit as each line parses | Reject | Would violate no-partial-plan behavior on a later malformed line. | +| Buffer all parsed edits | Reject | Unnecessary; parse and accumulate tiles per line to keep memory bounded by one edit plus the result set. | +| Scan all world/county tiles in Rust | Reject | Repeats baseline pain; enumerate each polygon's closed bounding tile range instead. | +| Use the public scaffold | Reject | It would modify `examples/`, violate the requested top-level layout, and add unrelated dependencies. | + +## Unresolved uncertainty + +- The real TypeScript baseline, real synthetic-county fixture, and reported + 40-minute environment were unavailable, so production equivalence and speedup + are unverified. +- The corrected validator covers recursive duplicates, simple nonzero rings, + hole containment/non-overlap, and MultiPolygon exterior non-overlap. It is not + a formal implementation of every GeoJSON validity recommendation. +- The implementation treats GeoJSON edges as straight longitude/latitude + segments and tile rectangles as closed. That matches the local reference, but + the production reference's geodesic/planar convention was not supplied. +- Boundary decisions use a small floating-point tolerance. Exact vertical edge, + four-way corner, hole boundary, horizontal edge, and maximum-Mercator probes + passed, but a production corpus should add many generated boundary cases. +- The resource evidence is host-specific. The final workload is more diverse + than the initial 16-tile fixture but still synthetic. + Very large polygons generate proportionally more candidate tiles and a very + large final plan necessarily consumes more memory. +- Output order is lexicographic over rendered `z/x/y` strings. If the consumer + intended numeric tuple order instead, the schema must say so explicitly. + +## Scope and defect attribution + +No BogKit core defect was found because no BogKit runtime component fits this +workload and none is used. The unconditional scaffold dependencies are a small +onboarding issue. Missing production baseline evidence belongs to the trial +setup. Geometry-validity and evidence limits belong to this prototype. Git status +at handoff showed detached HEAD with only `?? trial-parcel-delta-tiles/`; no core +or public example was modified. diff --git a/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/demo.ndjson b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/demo.ndjson new file mode 100644 index 0000000..64ff421 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/demo.ndjson @@ -0,0 +1,3 @@ +{"id":"insert","new":{"type":"Polygon","coordinates":[[[-73.990,40.720],[-73.980,40.720],[-73.980,40.730],[-73.990,40.730],[-73.990,40.720]]]}} +{"id":"replace","old":{"type":"Polygon","coordinates":[[[-73.975,40.721],[-73.970,40.721],[-73.970,40.726],[-73.975,40.726],[-73.975,40.721]]]},"new":{"type":"Polygon","coordinates":[[[-73.974,40.722],[-73.969,40.722],[-73.969,40.727],[-73.974,40.727],[-73.974,40.722]]]}} +{"id":"hole","old":{"type":"Polygon","coordinates":[[[-74.010,40.710],[-73.995,40.710],[-73.995,40.735],[-74.010,40.735],[-74.010,40.710]],[[-74.005,40.715],[-74.000,40.715],[-74.000,40.725],[-74.005,40.725],[-74.005,40.715]]]}} diff --git a/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/malformed-coordinate.ndjson b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/malformed-coordinate.ndjson new file mode 100644 index 0000000..c75a003 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/malformed-coordinate.ndjson @@ -0,0 +1 @@ +{"id":"bad-coordinate","new":{"type":"Polygon","coordinates":[[[0,0],[181,0],[1,1],[0,0]]]}} diff --git a/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/malformed-nonfinite.ndjson b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/malformed-nonfinite.ndjson new file mode 100644 index 0000000..748725a --- /dev/null +++ b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/malformed-nonfinite.ndjson @@ -0,0 +1 @@ +{"id":"nonfinite","new":{"type":"Polygon","coordinates":[[[0,0],[1e400,0],[1,1],[0,0]]]}} diff --git a/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/malformed-open-ring.ndjson b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/malformed-open-ring.ndjson new file mode 100644 index 0000000..04068c2 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/malformed-open-ring.ndjson @@ -0,0 +1,2 @@ +{"id":"ok","new":{"type":"Polygon","coordinates":[[[-73.99,40.72],[-73.98,40.72],[-73.98,40.73],[-73.99,40.72]]]}} +{"id":"bad","new":{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,1]]]}} diff --git a/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/malformed-type.ndjson b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/malformed-type.ndjson new file mode 100644 index 0000000..67a22d3 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/fixtures/malformed-type.ndjson @@ -0,0 +1 @@ +{"id":"bad-type","new":{"type":"LineString","coordinates":[[0,0],[1,1]]}} diff --git a/developer-simulation/runs/2026-08-05--parcel-delta-tiles/scripts/generate-workload.ts b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/scripts/generate-workload.ts new file mode 100644 index 0000000..c75ebfe --- /dev/null +++ b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/scripts/generate-workload.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env node +import { writeFileSync } from "node:fs"; + +const output = process.argv[2]; +if (!output) { + process.stderr.write("usage: node scripts/generate-workload.ts OUTPUT.ndjson\n"); + process.exit(2); +} + +function seeded(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; +} + +const random = seeded(0x200faced); +const lines: string[] = []; + +type Position = [number, number]; +type Geometry = { type: "Polygon" | "MultiPolygon"; coordinates: unknown }; + +function ring( + centerX: number, + centerY: number, + vertices: number, + radius: number, + alternating = true, +): Position[] { + const positions: Position[] = []; + for (let vertex = 0; vertex < vertices; vertex += 1) { + const angle = (vertex / vertices) * Math.PI * 2; + const localRadius = alternating && vertex % 2 === 1 ? radius * 0.72 : radius; + positions.push([ + centerX + Math.cos(angle) * localRadius, + centerY + Math.sin(angle) * localRadius, + ]); + } + positions.push(positions[0]); + return positions; +} + +function geometry( + mode: number, + centerX: number, + centerY: number, + vertices: number, + radius: number, +): Geometry { + if (mode === 1) { + const holeVertices = Math.max(4, Math.floor(vertices / 4)); + const outerVertices = vertices - holeVertices; + return { + type: "Polygon", + coordinates: [ + ring(centerX, centerY, outerVertices, radius), + ring(centerX, centerY, holeVertices, radius * 0.24, false), + ], + }; + } + if (mode === 2) { + const firstVertices = Math.floor(vertices / 2); + const secondVertices = vertices - firstVertices; + return { + type: "MultiPolygon", + coordinates: [ + [ring(centerX - radius * 1.6, centerY, firstVertices, radius)], + [ring(centerX + radius * 1.6, centerY, secondVertices, radius)], + ], + }; + } + return { type: "Polygon", coordinates: [ring(centerX, centerY, vertices, radius)] }; +} + +for (let edit = 0; edit < 1_000; edit += 1) { + const centerX = -74.10 + random() * 0.20; + const centerY = 40.65 + random() * 0.20; + const radii = [0.0002, 0.001, 0.004, 0.01]; + const radius = radii[edit % radii.length]; + const mode = Math.floor(edit / 3) % 3; + const operation = edit % 3; + const entry: Record = { id: `load-${edit}` }; + if (operation === 0) { + entry.new = geometry(mode, centerX, centerY, 200, radius); + } else if (operation === 1) { + entry.old = geometry(mode, centerX, centerY, 200, radius); + } else { + entry.old = geometry(mode, centerX, centerY, 100, radius); + entry.new = geometry(mode, centerX + radius * 0.3, centerY - radius * 0.2, 100, radius); + } + lines.push(JSON.stringify(entry)); +} +writeFileSync(output, `${lines.join("\n")}\n`); +process.stdout.write( + `wrote 1000 mixed-operation edits with 200 distinct vertices per line to ${output}\n`, +); diff --git a/developer-simulation/runs/2026-08-05--parcel-delta-tiles/scripts/reference.ts b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/scripts/reference.ts new file mode 100644 index 0000000..803e2d5 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/scripts/reference.ts @@ -0,0 +1,278 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +type Point = [number, number]; +type Ring = Point[]; +type Polygon = Ring[]; +type MultiPolygon = Polygon[]; +type Rect = { minX: number; minY: number; maxX: number; maxY: number }; + +const MIN_ZOOM = 12; +const MAX_ZOOM = 16; +const MAX_MERCATOR_LAT = 85.0511287798066; +const EPS = 1e-12; + +function fail(message: string): never { + throw new Error(message); +} + +function parsePosition(value: unknown, path: string): Point { + if (!Array.isArray(value) || value.length !== 2) { + fail(`${path} must contain exactly longitude and latitude`); + } + const [longitude, latitude] = value as unknown[]; + if (typeof longitude !== "number" || !Number.isFinite(longitude)) { + fail(`${path}[0] must be a finite number`); + } + if (typeof latitude !== "number" || !Number.isFinite(latitude)) { + fail(`${path}[1] must be a finite number`); + } + if (longitude < -180 || longitude > 180) { + fail(`${path}[0] longitude is outside [-180, 180]`); + } + if (latitude < -MAX_MERCATOR_LAT || latitude > MAX_MERCATOR_LAT) { + fail(`${path}[1] latitude is outside Web Mercator limits`); + } + return [longitude, latitude]; +} + +function parseRing(value: unknown, path: string): Ring { + if (!Array.isArray(value) || value.length < 4) { + fail(`${path} must contain at least four positions`); + } + const ring = value.map((position, index) => parsePosition(position, `${path}[${index}]`)); + const first = ring[0]; + const last = ring[ring.length - 1]; + if (first[0] !== last[0] || first[1] !== last[1]) { + fail(`${path} is open; first and last positions must match`); + } + return ring; +} + +function parsePolygon(value: unknown, path: string): Polygon { + if (!Array.isArray(value) || value.length === 0) { + fail(`${path} must contain an exterior ring`); + } + return value.map((ring, index) => parseRing(ring, `${path}[${index}]`)); +} + +function parseGeometry(value: unknown, field: string): MultiPolygon { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + fail(`${field} geometry must be an object`); + } + const object = value as Record; + let polygons: MultiPolygon; + if (object.type === "Polygon") { + polygons = [parsePolygon(object.coordinates, `${field}.coordinates`)]; + } else if (object.type === "MultiPolygon") { + if (!Array.isArray(object.coordinates) || object.coordinates.length === 0) { + fail(`${field}.coordinates must contain at least one polygon`); + } + polygons = object.coordinates.map((polygon, index) => + parsePolygon(polygon, `${field}.coordinates[${index}]`), + ); + } else { + fail(`${field}.type ${JSON.stringify(object.type)} is unsupported`); + } + const longitudes = polygons.flat(2).map((point) => point[0]); + if (Math.max(...longitudes) - Math.min(...longitudes) > 180) { + fail(`${field} crosses the antimeridian, which this prototype does not support`); + } + return polygons; +} + +export function parseNdjson(text: string): MultiPolygon[] { + const lines = text.split(/\r?\n/); + if (lines.at(-1) === "") lines.pop(); + const geometries: MultiPolygon[] = []; + for (const [index, line] of lines.entries()) { + const lineNumber = index + 1; + if (line.trim() === "") fail(`input line ${lineNumber}: blank line`); + try { + const value: unknown = JSON.parse(line); + if (value === null || typeof value !== "object" || Array.isArray(value)) { + fail("edit must be a JSON object"); + } + const edit = value as Record; + if (typeof edit.id !== "string" || edit.id.length === 0) { + fail("id must be a non-empty string"); + } + let count = 0; + for (const field of ["old", "new"] as const) { + if (edit[field] !== undefined && edit[field] !== null) { + geometries.push(parseGeometry(edit[field], field)); + count += 1; + } + } + if (count === 0) fail("at least one of old or new must contain a geometry"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith("input line ")) throw error; + fail(`input line ${lineNumber}: ${message}`); + } + } + return geometries; +} + +function bounds(polygon: Polygon): Rect { + const points = polygon.flat(); + return { + minX: Math.min(...points.map(([x]) => x)), + minY: Math.min(...points.map(([, y]) => y)), + maxX: Math.max(...points.map(([x]) => x)), + maxY: Math.max(...points.map(([, y]) => y)), + }; +} + +function lonToWorldX(longitude: number, n: number): number { + return ((longitude + 180) / 360) * n; +} + +function latToWorldY(latitude: number, n: number): number { + const radians = (latitude * Math.PI) / 180; + return ((1 - Math.asinh(Math.tan(radians)) / Math.PI) / 2) * n; +} + +function touchingRange(min: number, max: number, n: number): [number, number] { + const nearInteger = (value: number) => Math.abs(value - Math.round(value)) <= 1e-10; + const lower = nearInteger(min) ? Math.round(min) - 1 : Math.floor(min); + const upper = nearInteger(max) ? Math.round(max) : Math.floor(max); + return [Math.max(0, lower), Math.min(n - 1, upper)]; +} + +function worldYToLatitude(y: number, n: number): number { + return (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * 180) / Math.PI; +} + +function tileRect(z: number, x: number, y: number): Rect { + const n = 2 ** z; + return { + minX: (x / n) * 360 - 180, + maxX: ((x + 1) / n) * 360 - 180, + minY: worldYToLatitude(y + 1, n), + maxY: worldYToLatitude(y, n), + }; +} + +function segmentIntersectsRect([ax, ay]: Point, [bx, by]: Point, rect: Rect): boolean { + const dx = bx - ax; + const dy = by - ay; + let tMin = 0; + let tMax = 1; + const clips: [number, number][] = [ + [-dx, ax - rect.minX], + [dx, rect.maxX - ax], + [-dy, ay - rect.minY], + [dy, rect.maxY - ay], + ]; + for (const [p, q] of clips) { + if (Math.abs(p) <= EPS) { + if (q < -EPS) return false; + } else { + const ratio = q / p; + if (p < 0) tMin = Math.max(tMin, ratio); + else tMax = Math.min(tMax, ratio); + if (tMin - tMax > EPS) return false; + } + } + return true; +} + +type Location = "outside" | "inside" | "boundary"; + +function pointOnSegment([px, py]: Point, [ax, ay]: Point, [bx, by]: Point): boolean { + const cross = (bx - ax) * (py - ay) - (by - ay) * (px - ax); + const scale = Math.abs(bx - ax) + Math.abs(by - ay) + 1; + return ( + Math.abs(cross) <= EPS * scale && + px >= Math.min(ax, bx) - EPS && + px <= Math.max(ax, bx) + EPS && + py >= Math.min(ay, by) - EPS && + py <= Math.max(ay, by) + EPS + ); +} + +function pointInRing(point: Point, ring: Ring): Location { + let inside = false; + for (let index = 0; index + 1 < ring.length; index += 1) { + const a = ring[index]; + const b = ring[index + 1]; + if (pointOnSegment(point, a, b)) return "boundary"; + if ((a[1] > point[1]) !== (b[1] > point[1])) { + const crossingX = ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0]; + if (crossingX > point[0]) inside = !inside; + } + } + return inside ? "inside" : "outside"; +} + +function pointInFilledPolygon(point: Point, polygon: Polygon): boolean { + const exterior = pointInRing(point, polygon[0]); + if (exterior === "outside") return false; + if (exterior === "boundary") return true; + return !polygon.slice(1).some((hole) => pointInRing(point, hole) === "inside"); +} + +function polygonIntersectsRect(polygon: Polygon, rect: Rect): boolean { + for (const ring of polygon) { + for (let index = 0; index + 1 < ring.length; index += 1) { + if (segmentIntersectsRect(ring[index], ring[index + 1], rect)) return true; + } + } + const corners: Point[] = [ + [rect.minX, rect.minY], + [rect.minX, rect.maxY], + [rect.maxX, rect.minY], + [rect.maxX, rect.maxY], + ]; + return corners.some((corner) => pointInFilledPolygon(corner, polygon)); +} + +export function fullScanPlan(geometries: MultiPolygon[]): string[] { + const polygons = geometries.flat(); + const countyBounds = polygons.map(bounds).reduce((a, b) => ({ + minX: Math.min(a.minX, b.minX), + minY: Math.min(a.minY, b.minY), + maxX: Math.max(a.maxX, b.maxX), + maxY: Math.max(a.maxY, b.maxY), + })); + const output: string[] = []; + for (let z = MIN_ZOOM; z <= MAX_ZOOM; z += 1) { + const n = 2 ** z; + const [xStart, xEnd] = touchingRange( + lonToWorldX(countyBounds.minX, n), + lonToWorldX(countyBounds.maxX, n), + n, + ); + const yA = latToWorldY(countyBounds.minY, n); + const yB = latToWorldY(countyBounds.maxY, n); + const [yStart, yEnd] = touchingRange(Math.min(yA, yB), Math.max(yA, yB), n); + for (let x = xStart; x <= xEnd; x += 1) { + for (let y = yStart; y <= yEnd; y += 1) { + const rect = tileRect(z, x, y); + if (polygons.some((polygon) => polygonIntersectsRect(polygon, rect))) { + output.push(`${z}/${x}/${y}`); + } + } + } + } + return output.sort(); +} + +function main(): void { + const path = process.argv[2] ?? "-"; + const text = path === "-" ? readFileSync(0, "utf8") : readFileSync(path, "utf8"); + const output = fullScanPlan(parseNdjson(text)); + if (output.length > 0) process.stdout.write(`${output.join("\n")}\n`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } +} diff --git a/developer-simulation/runs/2026-08-05--parcel-delta-tiles/scripts/verify.ts b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/scripts/verify.ts new file mode 100644 index 0000000..0088f89 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/scripts/verify.ts @@ -0,0 +1,321 @@ +#!/usr/bin/env node +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +type Position = [number, number]; +type Geometry = { type: "Polygon" | "MultiPolygon"; coordinates: unknown }; +type Edit = { id: string; old?: Geometry | null; new?: Geometry | null }; + +const binary = resolve(process.argv[2] ?? "target/release/parcel-delta-tiles"); +const root = resolve(fileURLToPath(new URL("..", import.meta.url))); +const reference = join(root, "scripts/reference.ts"); +const work = mkdtempSync(join(tmpdir(), "parcel-delta-verify-")); + +function rectangle(minX: number, minY: number, maxX: number, maxY: number): Geometry { + return { + type: "Polygon", + coordinates: [[ + [minX, minY], + [maxX, minY], + [maxX, maxY], + [minX, maxY], + [minX, minY], + ]], + }; +} + +function ndjson(edits: Edit[]): string { + return `${edits.map((edit) => JSON.stringify(edit)).join("\n")}\n`; +} + +function run(command: string, args: string[]) { + return spawnSync(command, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); +} + +function compare(label: string, edits: Edit[]): string { + const path = join(work, `${label}.ndjson`); + writeFileSync(path, ndjson(edits)); + const actual = run(binary, [path]); + const expected = run(process.execPath, [reference, path]); + if (actual.status !== 0) throw new Error(`${label}: Rust failed: ${actual.stderr}`); + if (expected.status !== 0) throw new Error(`${label}: reference failed: ${expected.stderr}`); + if (actual.stdout !== expected.stdout) { + const actualLines = actual.stdout.trim().split("\n"); + const expectedLines = expected.stdout.trim().split("\n"); + throw new Error( + `${label}: mismatch; Rust=${actualLines.length} tiles reference=${expectedLines.length} tiles`, + ); + } + const count = actual.stdout === "" ? 0 : actual.stdout.trim().split("\n").length; + console.log(`mirror ${label}: exact (${count} tiles)`); + return actual.stdout; +} + +function tileLongitude(x: number, z: number): number { + return (x / 2 ** z) * 360 - 180; +} + +function tileLatitude(y: number, z: number): number { + return (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / 2 ** z))) * 180) / Math.PI; +} + +function seeded(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; +} + +function seededEdits(count: number, seed: number): Edit[] { + const random = seeded(seed); + const edits: Edit[] = []; + for (let index = 0; index < count; index += 1) { + const x = -73.991 + random() * 0.008; + const y = 40.721 + random() * 0.008; + const width = 0.00003 + random() * 0.00008; + const height = 0.00003 + random() * 0.00008; + const shape = rectangle(x, y, x + width, y + height); + if (index % 3 === 0) edits.push({ id: `seed-${index}`, new: shape }); + else if (index % 3 === 1) edits.push({ id: `seed-${index}`, old: shape }); + else { + edits.push({ + id: `seed-${index}`, + old: shape, + new: rectangle(x + 0.00002, y - 0.00001, x + width + 0.00002, y + height - 0.00001), + }); + } + } + return edits; +} + +function shuffle(input: T[], seed: number): T[] { + const output = [...input]; + const random = seeded(seed); + for (let index = output.length - 1; index > 0; index -= 1) { + const other = Math.floor(random() * (index + 1)); + [output[index], output[other]] = [output[other], output[index]]; + } + return output; +} + +function requireMalformed(label: string, fixture: string, line: number, fragment: string): void { + requireMalformedText(label, readFileSync(join(root, fixture), "utf8"), line, fragment); +} + +function requireMalformedText(label: string, contents: string, line: number, fragment: string): void { + const path = join(work, `malformed-${label}.ndjson`); + writeFileSync(path, contents.endsWith("\n") ? contents : `${contents}\n`); + const first = run(binary, [path]); + const second = run(binary, [path]); + if (first.status === 0 || first.stdout !== "") { + throw new Error(`${label}: expected nonzero status and empty stdout`); + } + if (!first.stderr.includes(`input line ${line}:`) || !first.stderr.includes(fragment)) { + throw new Error(`${label}: unexpected diagnostic: ${first.stderr}`); + } + if (first.status !== second.status || first.stdout !== second.stdout || first.stderr !== second.stderr) { + throw new Error(`${label}: failure was not deterministic`); + } + console.log(`malformed ${label}: deterministic line ${line}, empty stdout`); +} + +function rectangleTileSet(edits: Edit[]): string { + const tiles = new Set(); + for (const edit of edits) { + for (const geometry of [edit.old, edit.new]) { + if (!geometry || geometry.type !== "Polygon") continue; + const ring = geometry.coordinates as Position[][]; + const xs = ring[0].map(([x]) => x); + const ys = ring[0].map(([, y]) => y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + for (let z = 12; z <= 16; z += 1) { + const n = 2 ** z; + const xWorld = (longitude: number) => ((longitude + 180) / 360) * n; + const yWorld = (latitude: number) => { + const radians = (latitude * Math.PI) / 180; + return ((1 - Math.asinh(Math.tan(radians)) / Math.PI) / 2) * n; + }; + const touching = (min: number, max: number): [number, number] => { + const near = (value: number) => Math.abs(value - Math.round(value)) <= 1e-10; + const lower = near(min) ? Math.round(min) - 1 : Math.floor(min); + const upper = near(max) ? Math.round(max) : Math.floor(max); + return [Math.max(0, lower), Math.min(n - 1, upper)]; + }; + const [xStart, xEnd] = touching(xWorld(minX), xWorld(maxX)); + const yA = yWorld(minY); + const yB = yWorld(maxY); + const [yStart, yEnd] = touching(Math.min(yA, yB), Math.max(yA, yB)); + for (let x = xStart; x <= xEnd; x += 1) { + for (let y = yStart; y <= yEnd; y += 1) tiles.add(`${z}/${x}/${y}`); + } + } + } + } + const sorted = [...tiles].sort(); + return sorted.length === 0 ? "" : `${sorted.join("\n")}\n`; +} + +function verifyAnalyticalRectangles(): void { + const random = seeded(0xdecafbad); + const edits: Edit[] = []; + for (let index = 0; index < 500; index += 1) { + const x = -170 + random() * 340; + const y = -70 + random() * 140; + const width = 0.001 + random() * 0.05; + const height = 0.001 + random() * 0.05; + edits.push({ id: `analytic-${index}`, new: rectangle(x, y, x + width, y + height) }); + } + const path = join(work, "analytic-rectangles.ndjson"); + writeFileSync(path, ndjson(edits)); + const actual = run(binary, [path]); + if (actual.status !== 0) throw new Error(`analytical rectangles failed: ${actual.stderr}`); + const expected = rectangleTileSet(edits); + if (actual.stdout !== expected) throw new Error("analytical rectangle tile set differs"); + console.log(`analytical rectangles: 500 edits exact (${expected.trim().split("\n").length} tiles)`); +} + +try { + const insertion = { id: "insertion", new: rectangle(-73.99, 40.72, -73.98, 40.73) }; + compare("insertion", [insertion]); + compare("deletion", [{ id: "deletion", old: insertion.new }]); + compare("translation", [{ + id: "translation", + old: rectangle(-73.99, 40.72, -73.985, 40.725), + new: rectangle(-73.98, 40.73, -73.975, 40.735), + }]); + compare("concavity", [{ + id: "concavity", + new: { + type: "Polygon", + coordinates: [[ + [-74.00, 40.71], [-73.98, 40.71], [-73.98, 40.72], [-73.99, 40.72], + [-73.99, 40.73], [-74.00, 40.73], [-74.00, 40.71], + ]], + }, + }]); + + const holeTile = { z: 16, x: 19301, y: 24640 }; + const left = tileLongitude(holeTile.x, holeTile.z); + const right = tileLongitude(holeTile.x + 1, holeTile.z); + const top = tileLatitude(holeTile.y, holeTile.z); + const bottom = tileLatitude(holeTile.y + 1, holeTile.z); + const dx = right - left; + const dy = top - bottom; + const hole: Position[] = [ + [left - dx / 4, bottom - dy / 4], [right + dx / 4, bottom - dy / 4], + [right + dx / 4, top + dy / 4], [left - dx / 4, top + dy / 4], + [left - dx / 4, bottom - dy / 4], + ]; + const outer: Position[] = [ + [left - dx * 2, bottom - dy * 2], [right + dx * 2, bottom - dy * 2], + [right + dx * 2, top + dy * 2], [left - dx * 2, top + dy * 2], + [left - dx * 2, bottom - dy * 2], + ]; + const holeOutput = compare("holes", [{ + id: "holes", + new: { type: "Polygon", coordinates: [outer, hole] }, + }]); + if (holeOutput.split("\n").includes(`${holeTile.z}/${holeTile.x}/${holeTile.y}`)) { + throw new Error("holes: tile wholly inside the hole was included"); + } + + compare("multipolygon", [{ + id: "multipolygon", + new: { + type: "MultiPolygon", + coordinates: [ + rectangle(-73.995, 40.715, -73.993, 40.717).coordinates, + rectangle(-73.975, 40.732, -73.972, 40.734).coordinates, + ], + }, + }]); + + const boundary = tileLongitude(19301, 16); + const boundaryOutput = compare("boundary-touch", [{ + id: "boundary-touch", + new: rectangle(boundary, 40.72, boundary + 0.0002, 40.721), + }]); + const boundaryXs = new Set( + boundaryOutput.trim().split("\n").filter((tile) => tile.startsWith("16/")).map((tile) => tile.split("/")[1]), + ); + if (!boundaryXs.has("19300") || !boundaryXs.has("19301")) { + throw new Error("boundary-touch: did not include both tiles sharing the touched edge"); + } + + const tenThousand = seededEdits(10_000, 0x5eed1234); + compare("seeded-10000", tenThousand); + verifyAnalyticalRectangles(); + + const permutationEdits = seededEdits(200, 0xc0ffee); + const basePath = join(work, "permutation-base.ndjson"); + writeFileSync(basePath, ndjson(permutationEdits)); + const base = run(binary, [basePath]); + if (base.status !== 0) throw new Error(`permutation base failed: ${base.stderr}`); + for (let index = 0; index < 10; index += 1) { + const path = join(work, `permutation-${index}.ndjson`); + writeFileSync(path, ndjson(shuffle(permutationEdits, 1000 + index))); + const result = run(binary, [path]); + if (result.status !== 0 || result.stdout !== base.stdout) { + throw new Error(`permutation ${index + 1}: output differs`); + } + } + console.log("permutations: 10/10 byte-identical"); + + requireMalformed("open-ring", "fixtures/malformed-open-ring.ndjson", 2, "is open"); + requireMalformed("coordinate", "fixtures/malformed-coordinate.ndjson", 1, "longitude"); + requireMalformed("nonfinite", "fixtures/malformed-nonfinite.ndjson", 1, "number out of range"); + requireMalformed("unsupported-type", "fixtures/malformed-type.ndjson", 1, "unsupported"); + const valid = ndjson([{ id: "valid", new: rectangle(-73.99, 40.72, -73.98, 40.73) }]); + requireMalformedText( + "duplicate-edit", + `${valid.trim()}\n{"id":"dup","new":null,"new":{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}}\n`, + 2, + "duplicate object member", + ); + requireMalformedText( + "duplicate-geometry", + '{"id":"dup","new":{"type":"LineString","type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}}\n', + 1, + "duplicate object member", + ); + requireMalformedText( + "duplicate-nested", + '{"id":"dup","meta":{"value":1,"value":2},"new":{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}}\n', + 1, + "duplicate object member", + ); + requireMalformedText( + "self-intersection", + '{"id":"bow","new":{"type":"Polygon","coordinates":[[[0,0],[2,2],[0,2],[2,0],[0,0]]]}}\n', + 1, + "self-intersects", + ); + requireMalformedText( + "zero-area", + '{"id":"zero","new":{"type":"Polygon","coordinates":[[[0,0],[1,1],[2,2],[0,0]]]}}\n', + 1, + "zero area", + ); + requireMalformedText( + "hole-outside", + '{"id":"hole","new":{"type":"Polygon","coordinates":[[[0,0],[2,0],[2,2],[0,2],[0,0]],[[3,3],[4,3],[4,4],[3,4],[3,3]]]}}\n', + 1, + "strictly inside", + ); + requireMalformedText( + "hole-overlap", + '{"id":"holes","new":{"type":"Polygon","coordinates":[[[0,0],[5,0],[5,5],[0,5],[0,0]],[[1,1],[3,1],[3,3],[1,3],[1,1]],[[2,2],[4,2],[4,4],[2,4],[2,2]]]}}\n', + 1, + "overlap or nest", + ); + console.log("verification complete"); +} finally { + rmSync(work, { recursive: true, force: true }); +} diff --git a/developer-simulation/runs/2026-08-05--parcel-delta-tiles/src/lib.rs b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/src/lib.rs new file mode 100644 index 0000000..5b9a873 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/src/lib.rs @@ -0,0 +1,779 @@ +use std::collections::BTreeSet; +use std::f64::consts::PI; +use std::io::BufRead; + +use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Number, Value}; + +const MIN_ZOOM: u8 = 12; +const MAX_ZOOM: u8 = 16; +const MAX_MERCATOR_LAT: f64 = 85.051_128_779_806_6; +const EPS: f64 = 1e-12; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct Tile { + pub z: u8, + pub x: u32, + pub y: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Point { + x: f64, + y: f64, +} + +type Ring = Vec; +type Polygon = Vec; +type MultiPolygon = Vec; + +#[derive(Debug)] +struct Edit { + geometries: Vec, +} + +#[derive(Clone, Copy, Debug)] +struct Rect { + min_x: f64, + min_y: f64, + max_x: f64, + max_y: f64, +} + +struct StrictValueSeed; + +impl<'de> DeserializeSeed<'de> for StrictValueSeed { + type Value = Value; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(StrictValueVisitor) + } +} + +struct StrictValueVisitor; + +impl<'de> Visitor<'de> for StrictValueVisitor { + type Value = Value; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JSON value without duplicate object members") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("non-finite JSON number")) + } + + fn visit_str(self, value: &str) -> Result { + Ok(Value::String(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(Value::String(value)) + } + + fn visit_none(self) -> Result { + Ok(Value::Null) + } + + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element_seed(StrictValueSeed)? { + values.push(value); + } + Ok(Value::Array(values)) + } + + fn visit_map(self, mut object: A) -> Result + where + A: MapAccess<'de>, + { + let mut values = Map::new(); + while let Some(key) = object.next_key::()? { + if values.contains_key(&key) { + return Err(de::Error::custom(format!( + "duplicate object member `{key}`" + ))); + } + values.insert(key, object.next_value_seed(StrictValueSeed)?); + } + Ok(Value::Object(values)) + } +} + +fn parse_json_strict(text: &str) -> Result { + let mut deserializer = serde_json::Deserializer::from_str(text); + let value = StrictValueSeed.deserialize(&mut deserializer)?; + deserializer.end()?; + Ok(value) +} + +pub fn plan(reader: impl BufRead) -> Result, String> { + let mut tiles = BTreeSet::new(); + for (index, line_result) in reader.lines().enumerate() { + let line_number = index + 1; + let line = line_result.map_err(|error| format!("input line {line_number}: {error}"))?; + if line.trim().is_empty() { + return Err(format!("input line {line_number}: blank line")); + } + let value = parse_json_strict(&line) + .map_err(|error| format!("input line {line_number}: invalid JSON: {error}"))?; + let edit = + parse_edit(&value).map_err(|error| format!("input line {line_number}: {error}"))?; + for geometry in &edit.geometries { + collect_geometry_tiles(geometry, &mut tiles); + } + } + Ok(tiles) +} + +pub fn format_plan(tiles: &BTreeSet) -> Vec { + let mut lines: Vec<_> = tiles + .iter() + .map(|tile| format!("{}/{}/{}", tile.z, tile.x, tile.y)) + .collect(); + lines.sort_unstable(); + lines +} + +fn parse_edit(value: &Value) -> Result { + let object = value + .as_object() + .ok_or_else(|| "edit must be a JSON object".to_string())?; + match object.get("id") { + Some(Value::String(id)) if !id.is_empty() => {} + _ => return Err("id must be a non-empty string".to_string()), + } + + let mut geometries = Vec::with_capacity(2); + for field in ["old", "new"] { + match object.get(field) { + None | Some(Value::Null) => {} + Some(geometry) => geometries.push(parse_geometry(geometry, field)?), + } + } + if geometries.is_empty() { + return Err("at least one of old or new must contain a geometry".to_string()); + } + Ok(Edit { geometries }) +} + +fn parse_geometry(value: &Value, field: &str) -> Result { + let object = value + .as_object() + .ok_or_else(|| format!("{field} geometry must be an object"))?; + let geometry_type = required_string(object, "type", field)?; + let coordinates = object + .get("coordinates") + .ok_or_else(|| format!("{field}.coordinates is required"))?; + let polygons = match geometry_type { + "Polygon" => vec![parse_polygon(coordinates, &format!("{field}.coordinates"))?], + "MultiPolygon" => parse_multipolygon(coordinates, &format!("{field}.coordinates"))?, + other => return Err(format!("{field}.type {other:?} is unsupported")), + }; + + let (min_lon, max_lon) = longitude_extent(&polygons); + if max_lon - min_lon > 180.0 { + return Err(format!( + "{field} crosses the antimeridian, which this prototype does not support" + )); + } + Ok(polygons) +} + +fn required_string<'a>( + object: &'a Map, + key: &str, + context: &str, +) -> Result<&'a str, String> { + object + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| format!("{context}.{key} must be a string")) +} + +fn parse_multipolygon(value: &Value, path: &str) -> Result { + let values = value + .as_array() + .ok_or_else(|| format!("{path} must be an array"))?; + if values.is_empty() { + return Err(format!("{path} must contain at least one polygon")); + } + let polygons: MultiPolygon = values + .iter() + .enumerate() + .map(|(index, polygon)| parse_polygon(polygon, &format!("{path}[{index}]"))) + .collect::>()?; + validate_multipolygon(&polygons, path)?; + Ok(polygons) +} + +fn parse_polygon(value: &Value, path: &str) -> Result { + let values = value + .as_array() + .ok_or_else(|| format!("{path} must be an array"))?; + if values.is_empty() { + return Err(format!("{path} must contain an exterior ring")); + } + let polygon: Polygon = values + .iter() + .enumerate() + .map(|(index, ring)| parse_ring(ring, &format!("{path}[{index}]"))) + .collect::>()?; + validate_polygon(&polygon, path)?; + Ok(polygon) +} + +fn parse_ring(value: &Value, path: &str) -> Result { + let values = value + .as_array() + .ok_or_else(|| format!("{path} must be an array"))?; + if values.len() < 4 { + return Err(format!("{path} must contain at least four positions")); + } + let points: Ring = values + .iter() + .enumerate() + .map(|(index, position)| parse_position(position, &format!("{path}[{index}]"))) + .collect::>()?; + if points.first() != points.last() { + return Err(format!( + "{path} is open; first and last positions must match" + )); + } + if points.windows(2).any(|segment| segment[0] == segment[1]) { + return Err(format!("{path} contains a zero-length edge")); + } + let segment_count = points.len() - 1; + for first in 0..segment_count { + for second in (first + 1)..segment_count { + let adjacent = second == first + 1 || (first == 0 && second + 1 == segment_count); + if !adjacent + && segments_intersect( + points[first], + points[first + 1], + points[second], + points[second + 1], + ) + { + return Err(format!("{path} self-intersects")); + } + } + } + if signed_twice_area(&points).abs() <= EPS { + return Err(format!("{path} has zero area")); + } + Ok(points) +} + +fn signed_twice_area(ring: &Ring) -> f64 { + ring.windows(2) + .map(|segment| segment[0].x * segment[1].y - segment[1].x * segment[0].y) + .sum() +} + +fn validate_polygon(polygon: &Polygon, path: &str) -> Result<(), String> { + let exterior = &polygon[0]; + for (hole_index, hole) in polygon[1..].iter().enumerate() { + let hole_path = format!("{path}[{}]", hole_index + 1); + if point_in_ring(hole[0], exterior) != Location::Inside || rings_intersect(exterior, hole) { + return Err(format!( + "{hole_path} must be strictly inside the exterior ring" + )); + } + } + + for first in 1..polygon.len() { + for second in (first + 1)..polygon.len() { + if rings_intersect(&polygon[first], &polygon[second]) + || point_in_ring(polygon[first][0], &polygon[second]) != Location::Outside + || point_in_ring(polygon[second][0], &polygon[first]) != Location::Outside + { + return Err(format!( + "{path}[{first}] and {path}[{second}] overlap or nest" + )); + } + } + } + Ok(()) +} + +fn validate_multipolygon(polygons: &MultiPolygon, path: &str) -> Result<(), String> { + for first in 0..polygons.len() { + for second in (first + 1)..polygons.len() { + let first_exterior = &polygons[first][0]; + let second_exterior = &polygons[second][0]; + if rings_intersect(first_exterior, second_exterior) + || point_in_ring(first_exterior[0], second_exterior) != Location::Outside + || point_in_ring(second_exterior[0], first_exterior) != Location::Outside + { + return Err(format!( + "{path}[{first}] and {path}[{second}] overlap or nest" + )); + } + } + } + Ok(()) +} + +fn rings_intersect(first: &Ring, second: &Ring) -> bool { + first.windows(2).any(|a| { + second + .windows(2) + .any(|b| segments_intersect(a[0], a[1], b[0], b[1])) + }) +} + +fn segments_intersect(a: Point, b: Point, c: Point, d: Point) -> bool { + let orientation = |first: Point, second: Point, third: Point| { + (second.x - first.x) * (third.y - first.y) - (second.y - first.y) * (third.x - first.x) + }; + let ab_c = orientation(a, b, c); + let ab_d = orientation(a, b, d); + let cd_a = orientation(c, d, a); + let cd_b = orientation(c, d, b); + + if ((ab_c > EPS && ab_d < -EPS) || (ab_c < -EPS && ab_d > EPS)) + && ((cd_a > EPS && cd_b < -EPS) || (cd_a < -EPS && cd_b > EPS)) + { + return true; + } + (ab_c.abs() <= EPS && point_on_segment(c, a, b)) + || (ab_d.abs() <= EPS && point_on_segment(d, a, b)) + || (cd_a.abs() <= EPS && point_on_segment(a, c, d)) + || (cd_b.abs() <= EPS && point_on_segment(b, c, d)) +} + +fn parse_position(value: &Value, path: &str) -> Result { + let values = value + .as_array() + .ok_or_else(|| format!("{path} must be a two-number position"))?; + if values.len() != 2 { + return Err(format!( + "{path} must contain exactly longitude and latitude" + )); + } + let x = finite_number(&values[0], &format!("{path}[0]"))?; + let y = finite_number(&values[1], &format!("{path}[1]"))?; + if !(-180.0..=180.0).contains(&x) { + return Err(format!("{path}[0] longitude is outside [-180, 180]")); + } + if !(-MAX_MERCATOR_LAT..=MAX_MERCATOR_LAT).contains(&y) { + return Err(format!("{path}[1] latitude is outside Web Mercator limits")); + } + Ok(Point { x, y }) +} + +fn finite_number(value: &Value, path: &str) -> Result { + let number = value + .as_f64() + .ok_or_else(|| format!("{path} must be a finite number"))?; + if !number.is_finite() { + return Err(format!("{path} must be a finite number")); + } + Ok(number) +} + +fn longitude_extent(polygons: &MultiPolygon) -> (f64, f64) { + polygons + .iter() + .flat_map(|polygon| polygon.iter()) + .flat_map(|ring| ring.iter()) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), point| { + (min.min(point.x), max.max(point.x)) + }) +} + +fn collect_geometry_tiles(geometry: &MultiPolygon, tiles: &mut BTreeSet) { + for polygon in geometry { + let bounds = polygon_bounds(polygon); + for z in MIN_ZOOM..=MAX_ZOOM { + let n = 1_u32 << z; + let x_min_world = longitude_to_world_x(bounds.min_x, n); + let x_max_world = longitude_to_world_x(bounds.max_x, n); + let y_a = latitude_to_world_y(bounds.min_y, n); + let y_b = latitude_to_world_y(bounds.max_y, n); + let (x_start, x_end) = touching_index_range(x_min_world, x_max_world, n); + let (y_start, y_end) = touching_index_range(y_a.min(y_b), y_a.max(y_b), n); + for x in x_start..=x_end { + for y in y_start..=y_end { + if polygon_intersects_rect(polygon, tile_rect(z, x, y)) { + tiles.insert(Tile { z, x, y }); + } + } + } + } + } +} + +fn polygon_bounds(polygon: &Polygon) -> Rect { + polygon.iter().flat_map(|ring| ring.iter()).fold( + Rect { + min_x: f64::INFINITY, + min_y: f64::INFINITY, + max_x: f64::NEG_INFINITY, + max_y: f64::NEG_INFINITY, + }, + |bounds, point| Rect { + min_x: bounds.min_x.min(point.x), + min_y: bounds.min_y.min(point.y), + max_x: bounds.max_x.max(point.x), + max_y: bounds.max_y.max(point.y), + }, + ) +} + +fn longitude_to_world_x(longitude: f64, n: u32) -> f64 { + (longitude + 180.0) / 360.0 * f64::from(n) +} + +fn latitude_to_world_y(latitude: f64, n: u32) -> f64 { + let radians = latitude.to_radians(); + (1.0 - radians.tan().asinh() / PI) / 2.0 * f64::from(n) +} + +fn touching_index_range(min: f64, max: f64, n: u32) -> (u32, u32) { + let lower = if near_integer(min) { + min.round() as i64 - 1 + } else { + min.floor() as i64 + }; + let upper = if near_integer(max) { + max.round() as i64 + } else { + max.floor() as i64 + }; + let last = i64::from(n) - 1; + (lower.clamp(0, last) as u32, upper.clamp(0, last) as u32) +} + +fn near_integer(value: f64) -> bool { + (value - value.round()).abs() <= 1e-10 +} + +fn tile_rect(z: u8, x: u32, y: u32) -> Rect { + let n = f64::from(1_u32 << z); + Rect { + min_x: f64::from(x) / n * 360.0 - 180.0, + max_x: f64::from(x + 1) / n * 360.0 - 180.0, + min_y: world_y_to_latitude(f64::from(y + 1), n), + max_y: world_y_to_latitude(f64::from(y), n), + } +} + +fn world_y_to_latitude(y: f64, n: f64) -> f64 { + (PI * (1.0 - 2.0 * y / n)).sinh().atan().to_degrees() +} + +fn polygon_intersects_rect(polygon: &Polygon, rect: Rect) -> bool { + if polygon.iter().any(|ring| { + ring.windows(2) + .any(|segment| segment_intersects_rect(segment[0], segment[1], rect)) + }) { + return true; + } + + let corners = [ + Point { + x: rect.min_x, + y: rect.min_y, + }, + Point { + x: rect.min_x, + y: rect.max_y, + }, + Point { + x: rect.max_x, + y: rect.min_y, + }, + Point { + x: rect.max_x, + y: rect.max_y, + }, + ]; + corners + .into_iter() + .any(|corner| point_in_filled_polygon(corner, polygon)) +} + +fn segment_intersects_rect(a: Point, b: Point, rect: Rect) -> bool { + let dx = b.x - a.x; + let dy = b.y - a.y; + let mut t_min: f64 = 0.0; + let mut t_max: f64 = 1.0; + for (p, q) in [ + (-dx, a.x - rect.min_x), + (dx, rect.max_x - a.x), + (-dy, a.y - rect.min_y), + (dy, rect.max_y - a.y), + ] { + if p.abs() <= EPS { + if q < -EPS { + return false; + } + } else { + let ratio = q / p; + if p < 0.0 { + t_min = t_min.max(ratio); + } else { + t_max = t_max.min(ratio); + } + if t_min - t_max > EPS { + return false; + } + } + } + true +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Location { + Outside, + Inside, + Boundary, +} + +fn point_in_filled_polygon(point: Point, polygon: &Polygon) -> bool { + match point_in_ring(point, &polygon[0]) { + Location::Outside => false, + Location::Boundary => true, + Location::Inside => !polygon[1..] + .iter() + .any(|hole| point_in_ring(point, hole) == Location::Inside), + } +} + +fn point_in_ring(point: Point, ring: &Ring) -> Location { + let mut inside = false; + for segment in ring.windows(2) { + let a = segment[0]; + let b = segment[1]; + if point_on_segment(point, a, b) { + return Location::Boundary; + } + if (a.y > point.y) != (b.y > point.y) { + let crossing_x = (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x; + if crossing_x > point.x { + inside = !inside; + } + } + } + if inside { + Location::Inside + } else { + Location::Outside + } +} + +fn point_on_segment(point: Point, a: Point, b: Point) -> bool { + let cross = (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x); + let scale = (b.x - a.x).abs() + (b.y - a.y).abs() + 1.0; + if cross.abs() > EPS * scale { + return false; + } + point.x >= a.x.min(b.x) - EPS + && point.x <= a.x.max(b.x) + EPS + && point.y >= a.y.min(b.y) - EPS + && point.y <= a.y.max(b.y) + EPS +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn plan_text(input: &str) -> Result, String> { + plan(Cursor::new(input)) + } + + #[test] + fn insertion_and_deletion_have_the_same_plan() { + let geometry = r#"{"type":"Polygon","coordinates":[[[-73.99,40.72],[-73.98,40.72],[-73.98,40.73],[-73.99,40.73],[-73.99,40.72]]]}"#; + let inserted = plan_text(&format!(r#"{{"id":"p","new":{geometry}}}"#)).unwrap(); + let deleted = plan_text(&format!(r#"{{"id":"p","old":{geometry}}}"#)).unwrap(); + assert_eq!(inserted, deleted); + assert!(!inserted.is_empty()); + } + + #[test] + fn exact_tile_boundary_touches_both_sides() { + let boundary = f64::from(19_301_u32) / f64::from(1_u32 << 16) * 360.0 - 180.0; + let input = format!( + r#"{{"id":"edge","new":{{"type":"Polygon","coordinates":[[[{boundary},40.7],[{},40.7],[{},40.72],[{boundary},40.72],[{boundary},40.7]]]}}}}"#, + boundary + 0.0002, + boundary + 0.0002, + ); + let tiles = plan_text(&input).unwrap(); + let xs: BTreeSet<_> = tiles + .iter() + .filter(|tile| tile.z == 16) + .map(|tile| tile.x) + .collect(); + assert!(xs.contains(&19_300)); + assert!(xs.contains(&19_301)); + } + + #[test] + fn exact_tile_corner_touches_all_four_neighbors() { + let z = 16; + let x = 19_301; + let y = 24_641; + let corner_x = longitude_to_world_x(tile_rect(z, x, y).min_x, 1_u32 << z); + let corner_y = latitude_to_world_y(tile_rect(z, x, y).max_y, 1_u32 << z); + assert!(near_integer(corner_x)); + assert!(near_integer(corner_y)); + let corner = tile_rect(z, x, y); + let input = format!( + r#"{{"id":"corner","new":{{"type":"Polygon","coordinates":[[[{},{}],[{},{}],[{},{}],[{},{}]]]}}}}"#, + corner.min_x, + corner.max_y, + corner.min_x + 0.0002, + corner.max_y, + corner.min_x, + corner.max_y - 0.0002, + corner.min_x, + corner.max_y, + ); + let tiles = plan_text(&input).unwrap(); + for expected in [ + Tile { + z, + x: x - 1, + y: y - 1, + }, + Tile { z, x, y: y - 1 }, + Tile { z, x: x - 1, y }, + Tile { z, x, y }, + ] { + assert!(tiles.contains(&expected), "missing {expected:?}"); + } + } + + #[test] + fn tile_wholly_inside_hole_is_excluded() { + let z = 16; + let x = 19_301; + let y = 24_641; + let tile = tile_rect(z, x, y); + let pad_x = tile.max_x - tile.min_x; + let pad_y = tile.max_y - tile.min_y; + let outer = vec![ + Point { + x: tile.min_x - pad_x, + y: tile.min_y - pad_y, + }, + Point { + x: tile.max_x + pad_x, + y: tile.min_y - pad_y, + }, + Point { + x: tile.max_x + pad_x, + y: tile.max_y + pad_y, + }, + Point { + x: tile.min_x - pad_x, + y: tile.max_y + pad_y, + }, + Point { + x: tile.min_x - pad_x, + y: tile.min_y - pad_y, + }, + ]; + let hole = vec![ + Point { + x: tile.min_x - pad_x / 4.0, + y: tile.min_y - pad_y / 4.0, + }, + Point { + x: tile.max_x + pad_x / 4.0, + y: tile.min_y - pad_y / 4.0, + }, + Point { + x: tile.max_x + pad_x / 4.0, + y: tile.max_y + pad_y / 4.0, + }, + Point { + x: tile.min_x - pad_x / 4.0, + y: tile.max_y + pad_y / 4.0, + }, + Point { + x: tile.min_x - pad_x / 4.0, + y: tile.min_y - pad_y / 4.0, + }, + ]; + assert!(!polygon_intersects_rect(&vec![outer, hole], tile)); + } + + #[test] + fn malformed_line_reports_line_and_returns_no_plan() { + let valid = + r#"{"id":"ok","new":{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}}"#; + let open = + r#"{"id":"bad","new":{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,1]]]}}"#; + let error = plan_text(&format!("{valid}\n{open}\n")).unwrap_err(); + assert!(error.starts_with("input line 2:")); + assert!(error.contains("is open")); + } + + #[test] + fn duplicate_members_are_rejected_recursively() { + let cases = [ + r#"{"id":"dup","new":null,"new":{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}}"#, + r#"{"id":"dup","new":{"type":"LineString","type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}}"#, + r#"{"id":"dup","meta":{"value":1,"value":2},"new":{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}}"#, + ]; + for input in cases { + let error = plan_text(input).unwrap_err(); + assert!(error.contains("duplicate object member"), "{error}"); + } + } + + #[test] + fn invalid_topology_is_rejected() { + let cases = [ + r#"{"id":"bow","new":{"type":"Polygon","coordinates":[[[0,0],[2,2],[0,2],[2,0],[0,0]]]}}"#, + r#"{"id":"zero","new":{"type":"Polygon","coordinates":[[[0,0],[0,0],[0,0],[0,0]]]}}"#, + r#"{"id":"outside-hole","new":{"type":"Polygon","coordinates":[[[0,0],[2,0],[2,2],[0,2],[0,0]],[[3,3],[4,3],[4,4],[3,4],[3,3]]]}}"#, + r#"{"id":"overlap-hole","new":{"type":"Polygon","coordinates":[[[0,0],[5,0],[5,5],[0,5],[0,0]],[[1,1],[3,1],[3,3],[1,3],[1,1]],[[2,2],[4,2],[4,4],[2,4],[2,2]]]}}"#, + ]; + for input in cases { + assert!(plan_text(input).is_err(), "accepted {input}"); + } + } + + #[test] + fn output_is_lexicographic_by_rendered_tile_id() { + let tiles = BTreeSet::from([ + Tile { z: 12, x: 20, y: 3 }, + Tile { z: 12, x: 3, y: 10 }, + Tile { z: 12, x: 3, y: 2 }, + ]); + assert_eq!(format_plan(&tiles), ["12/20/3", "12/3/10", "12/3/2"]); + } +} diff --git a/developer-simulation/runs/2026-08-05--parcel-delta-tiles/src/main.rs b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/src/main.rs new file mode 100644 index 0000000..75e6824 --- /dev/null +++ b/developer-simulation/runs/2026-08-05--parcel-delta-tiles/src/main.rs @@ -0,0 +1,37 @@ +use std::fs::File; +use std::io::{self, BufReader, Write}; +use std::process::ExitCode; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let mut args = std::env::args().skip(1); + let input = args.next().unwrap_or_else(|| "-".to_string()); + if args.next().is_some() { + return Err("usage: parcel-delta-tiles [INPUT.ndjson|-]".to_string()); + } + + let tiles = if input == "-" { + let stdin = io::stdin(); + parcel_delta_tiles::plan(BufReader::new(stdin.lock()))? + } else { + let file = File::open(&input).map_err(|error| format!("cannot open {input}: {error}"))?; + parcel_delta_tiles::plan(BufReader::new(file))? + }; + + // Nothing is written until the entire input has parsed and validated. + let stdout = io::stdout(); + let mut output = stdout.lock(); + for line in parcel_delta_tiles::format_plan(&tiles) { + writeln!(output, "{line}").map_err(|error| format!("cannot write plan: {error}"))?; + } + Ok(()) +} diff --git a/developer-simulation/runs/2026-08-06--caldav-recurrence/BASELINE.md b/developer-simulation/runs/2026-08-06--caldav-recurrence/BASELINE.md new file mode 100644 index 0000000..c80e581 --- /dev/null +++ b/developer-simulation/runs/2026-08-06--caldav-recurrence/BASELINE.md @@ -0,0 +1,69 @@ +# CalDAV recurrence baseline model + +This is the baseline supplied by the scenario, written before selecting a +BogKit component or designing the prototype. + +## Current data and write path + +- SQLite is authoritative. +- An event row contains a UID, start and end, a TZID, a recurrence rule, + exclusions, and occurrence overrides. +- The HTTP/CalDAV layer is already working and is outside this trial. +- The current expander walks daily, weekly, and monthly rules in local wall + time, then converts each result to UTC. +- An edit replaces the whole event row. +- Output rows are emitted in insertion order. + +## Baseline behavior versus the acceptance criteria + +The baseline has no explicit occurrence identity, no canonical distinction +between a local date and a local date-time, no documented policy for a DST gap +or fold, no ordered publication key, and no materialization checkpoint. It +therefore cannot establish the required guarantees by inspection: + +| Required guarantee | Baseline risk | +| --- | --- | +| Oracle agreement across DST gaps/folds | Local wall-time expansion is underspecified at nonexistent and repeated times. | +| All-day events keep their date | Treating an all-day date as local midnight before UTC conversion can move it to the prior or next UTC date. | +| Exclusions, cancellations, and overrides are stable | Whole-row replacement and UTC-only identity can lose the distinction between a deleted original occurrence and its replacement. | +| No duplicate `(UID, occurrence)` records | Insertion-order rows have no enforced unique occurrence key. | +| Replacement occurrences remain | An override needs the original recurrence identity plus its replacement payload; a row replacement alone does not provide that identity. | +| Reordered input and host-time-zone independence | Insertion order and implicit host conversion make output order and possibly values depend on input or process environment. | +| Single-event edit isolation | Replacing a whole row gives no dependency boundary for rebuilding only one UID. | +| Interruption recovery | There is no durable per-event/materialization state or atomic publish marker in the baseline. | +| No partial publication on invalid input | Validation and publication are not described as separate phases. | +| 128 MiB / five-second workload | The baseline gives no bounded-memory plan and has no measured implementation to benchmark. | + +## Minimal corrected model for the prototype + +The trial will represent an occurrence by `(uid, recurrence_id)` where the +recurrence ID is a local date for all-day events and a local wall-time value +for timed events. A replacement keeps that recurrence ID and changes only the +occurrence payload. A cancellation keeps the identity but emits no row. + +Expansion will happen in the event's declared zone using only the supplied +transition table. Floating times will use the supplied fixed transition table +named `FLOATING` (and never the host zone); UTC values will bypass local +conversion. All-day values will remain civil dates and will not be converted to +UTC. + +The output key will be `(uid, occurrence_kind, recurrence_id, start_utc_or_date)` +with a stable serialized sort order. Input validation will complete before the +temporary output is renamed into place. A per-UID state file will make a +single-event edit and an interrupted materialization resumable. + +## Component fit decision, before implementation + +Fold's `KeyedStream` is a good fit for one narrow boundary: keyed upsert and +remove semantics for event masters, with an atomic transaction and durable +retraction. Its `Table` sink also demonstrates the last-writer-wins behavior +needed by an occurrence key. Fold does not supply iCalendar recurrence rules, +civil-date arithmetic, DST gap/fold policy, supplied transition-table parsing, +SQLite authority, or atomic publication of a JSONL artifact. The trial will +therefore keep calendar semantics and publication in ordinary Rust and use a +small keyed Fold materialization only where that behavior is directly useful. + +This is a prototype fit assessment, not a claim about the unavailable +production service. The supplied 5,000-case oracle and reference machine are +not present in this checkout, so those acceptance points can only be exercised +with the local deterministic fixture and measured as a bounded demonstration. diff --git a/developer-simulation/runs/2026-08-06--caldav-recurrence/Cargo.toml b/developer-simulation/runs/2026-08-06--caldav-recurrence/Cargo.toml new file mode 100644 index 0000000..27c8f9a --- /dev/null +++ b/developer-simulation/runs/2026-08-06--caldav-recurrence/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "caldav-recurrence-prototype" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +chrono = { version = "0.4.45", default-features = false, features = ["std"] } +fold = { path = "../../../fold" } +libc = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" diff --git a/developer-simulation/runs/2026-08-06--caldav-recurrence/README.md b/developer-simulation/runs/2026-08-06--caldav-recurrence/README.md new file mode 100644 index 0000000..0a14dfc --- /dev/null +++ b/developer-simulation/runs/2026-08-06--caldav-recurrence/README.md @@ -0,0 +1,49 @@ +# CalDAV recurrence prototype + +This standalone CLI evaluates a deliberately small, deterministic calendar +occurrence model. It is a prototype for the August 6 developer-simulation +trial, not a CalDAV server or a replacement for an existing SQLite service. + +## Supported input + +Events are JSONL records with `uid`, `kind` (`timed` or `all_day`), `start`, +`end`, optional `tzid`, one `DAILY`, `WEEKLY`, or `MONTHLY` `rrule`, `exdate`, +and confirmed or cancelled occurrence overrides. Timed values use +`YYYY-MM-DDTHH:MM:SS`; UTC values may use RFC3339 offsets. All-day values are +civil dates. Transition JSON supplies the complete fixed offset history for +each named zone and a `FLOATING` zone. + +The model chooses the earlier UTC instant for a fall-back fold and shifts a +nonexistent spring-forward wall time forward by the gap. It rejects unsupported +rules, malformed values, duplicate canonical occurrence identities, overrides +that do not belong to the generated recurrence set, and invalid expansions. +All-day query intersection uses half-open civil dates: an endpoint exactly at +midnight excludes that date, while a partial-day endpoint includes the date it +touches. + +## Reproduce + +From this directory, with the checked-in lockfile and cached dependencies: + +```text +cargo test --offline --locked --all-targets +cargo fmt -- --check +cargo clippy --offline --locked --all-targets -- -D warnings +cargo run --offline --bin caldav-recurrence-prototype -- \ + --events fixtures/smoke-events.jsonl \ + --transitions fixtures/smoke-zones.json \ + --from 2026-03-07T00:00:00Z --to 2026-03-12T00:00:00Z \ + --output /private/tmp/caldav-smoke-output.jsonl \ + --state-dir /private/tmp/caldav-smoke-state +``` + +The full reviewed workload is generated and run from the trial checkout with +the archived generator, then compared under `TZ=UTC` and +`TZ=Pacific/Honolulu`. The exact commands and observed results are in +`evidence/REPORT.md`. + +Fold is used only for the event-master keyed upsert/remove boundary. Recurrence +semantics, civil-time conversion, SQLite authority, shard integrity, and +filesystem publication remain outside BogKit. The controlled interruption hook +tests preservation of the previous published output; it is not a claim about +process-crash, filesystem, power-loss, or directory-sync durability. diff --git a/developer-simulation/runs/2026-08-06--caldav-recurrence/evidence/REPORT.md b/developer-simulation/runs/2026-08-06--caldav-recurrence/evidence/REPORT.md new file mode 100644 index 0000000..d1fa70e --- /dev/null +++ b/developer-simulation/runs/2026-08-06--caldav-recurrence/evidence/REPORT.md @@ -0,0 +1,363 @@ +# BogKit daily-lab trial report + +Date: 2026-08-06 + +Scenario: CalDAV recurrence and time-zone correctness + +Scope: only `/private/tmp/bogkit-sim-2026-08-06-one-rEQ2PZ`. No other checkout, +prior lab report, developer-simulation directory, or GitHub surface was +inspected or changed. + +## Result in one paragraph + +The stated baseline is not sufficient for the required correctness or +determinism guarantees. A standalone prototype was built under `developer-simulation/runs/2026-08-06--caldav-recurrence/`. It +uses Fold only for the event-master upsert/remove boundary; calendar rules, +civil-time conversion, per-UID shards, and publication stay outside Fold. The +local test suite passes. A fresh release run over 5,000 masters and 2,000,000 +candidate occurrences, with 100 supplied transition tables, completed in 3.85 +seconds wall time and reported 17,547,264 bytes peak RSS on this host. A +second run reused all 5,000 fingerprinted shards in 2.93 seconds and produced +byte-identical output. The supplied +5,000-case oracle and production SQLite service are not present in this +checkout, so neither production correctness nor oracle agreement is claimed. + +## Ordered discovery and friction trail + +The commands below are in the order used for onboarding and inspection. + +1. `sed -n '1,260p' README.md` + + Observed the public onboarding path: run `scripts/new-project.sh`, then the + examples in the order `starter`, `timeseries`, `chat`, `search`. The README + describes Fold as a persistent, incrementally maintained dataflow engine; + it does not describe recurrence or time-zone primitives. + +2. `rg --files examples/starter | sort` + + Observed only `Cargo.toml` and `src/main.rs`. Reading both showed the + smallest Fold shape: `Stream`, `wtx`, `rtx`, `Count`, and `Bag`. The public + `starter` manifest also declares unused `anny` and `ese` dependencies. + +3. `cargo run -p starter` + + Observed a build failure in `ese` while its build script attempted to + download `target/ese-cache/model.safetensors`; name resolution was + unavailable. This is onboarding friction in the example dependency graph, + not a recurrence finding. + +4. `rg --files examples/timeseries | sort`, followed by reading its manifest + and `src/main.rs` + + Observed `KeyBy` + `Aggregate` + `Table`, explicit snapshot sorting, and + retraction-safe aggregates. + +5. `cargo run -p timeseries` + + Observed a successful run: six readings were materialized, hourly and daily + views were printed, one rainy reading was retracted, and the updated totals + were correct. This was the first public example that ran end to end in this + environment. + +6. `rg --files examples/chat | sort`, followed by reading its manifest and + `src/main.rs` + + Observed the documented single-ingest-thread pattern, transactional writes, + watch snapshots, and explicit sorting of client-facing state. + +7. `cargo run -p chat` + + Observed successful compilation and startup text, followed by a bind panic: + `Operation not permitted` at the listener bind. This is a sandbox network + restriction, not a BogKit dataflow defect. + +8. `rg --files examples/search | sort`, followed by reading its manifest and + `src/main.rs` + + Observed `KeyedStream`, `upsert`, remove-by-key, BM25, HNSW, and hybrid + search. The example is unrelated to calendar semantics but reinforced the + keyed replacement model. + +9. `cargo run -p search` + + Observed the same ESE model download/name-resolution failure as `starter`. + +10. Read the public Fold source documentation for `Stream`, `KeyedStream`, + `KeyedTx::upsert`, `KeyedTx::remove`, `Table`, `Aggregate`, and the public + tests for keyed transactions. + + Observed that keyed upserts retract the old record before inserting the new + record, transactions are atomic, reads use one snapshot, and reopening the + same path resumes state. Also observed that Fold has no recurrence, civil + time, transition-table, SQLite, or JSONL publication abstraction. + +11. Wrote [`BASELINE.md`](../BASELINE.md) before selecting a component. + +12. Added the standalone prototype, fixtures, tests, and this report under + `developer-simulation/runs/2026-08-06--caldav-recurrence/`; no root Cargo file, Fold source, or existing example was changed. + +13. The first fresh release workload attempt measured 30.09 seconds. Inspection + identified 5,000 per-shard fsyncs plus a second full read of all shards to + assemble the output. The publication path was changed to stream one sorted + atomic output pass while still retaining per-UID shards for recovery. + +14. The corrected fresh release workload measured 3.85 seconds wall time and + 17,547,264 bytes peak RSS. A second run reused all 5,000 shards after + verifying their byte lengths and SHA-256 fingerprints. Separate `TZ=UTC` + and `TZ=Pacific/Honolulu` runs produced byte-identical output. + +## Prototype model and boundaries + +The CLI is: + +```text +caldav-recurrence-prototype \ + --events EVENTS.jsonl \ + --transitions ZONES.json \ + --from RFC3339 --to RFC3339 \ + --output OCCURRENCES.jsonl \ + --state-dir STATE [--edits EDITS.jsonl] +``` + +The input format is deliberately small and documented in the CLI help and +fixtures. It supports timed and all-day events, one `DAILY`, `WEEKLY`, or +`MONTHLY` rule, interval/count/until, weekly `BYDAY`, monthly `BYMONTHDAY`, +EXDATE values, and confirmed or cancelled occurrence overrides. + +The prototype gives each occurrence a stable `(UID, recurrence_id)` identity. +All-day values remain civil dates. UTC values bypass local conversion. Local +and floating times use only the supplied transition table. The chosen policy is +explicit and deterministic: a fall-back fold chooses the earlier UTC instant; +a spring-forward gap shifts the nonexistent wall time forward by the gap. +Output is sorted by UID and recurrence ID and contains no summary, location, or +event text. + +Fold's `KeyedStream>` is used for the +event-master cache. A full validated input snapshot and optional edits are +applied as one keyed transaction; absent UIDs are removed, replacements retract +the old master, and the store is checkpointed. Each UID has a JSONL shard and +SHA-256/length fingerprint. A changed or tampered UID rebuilds only its shard. +All desired events are expanded and validated before the Fold event-store +transaction; the final output is written to a temporary file, synced, and +renamed only after all input and expansion work succeeds. An interrupted run +leaves the previous published output intact; the next run ignores any +unreferenced shard and rebuilds from the manifest. + +This is not a SQLite adapter and it is not a CalDAV server. JSONL stands in for +a validated export at the prototype boundary. The production integration must +keep SQLite authoritative and must connect the existing HTTP/CalDAV layer to +the same occurrence identity and publication rules. + +## Verification commands and observed results + +All Rust commands below were run with `--offline` after the public onboarding +attempts showed that network access was unavailable. + +```text +cargo fmt --manifest-path developer-simulation/runs/2026-08-06--caldav-recurrence/Cargo.toml -- --check +``` + +Final result: passed. One intermediate check correctly reported formatting +differences after the 100-zone fixture generator was added; `cargo fmt` fixed +them and the final check passed. + +```text +cargo test --manifest-path developer-simulation/runs/2026-08-06--caldav-recurrence/Cargo.toml --offline --all-targets +``` + +Final result: 10 integration tests passed. They cover DST gap/fold conversion, +all-day and floating times, partial-day query intersection, daily/weekly/monthly +rules, exclusions, canonical and unseen override rejection, stable ordering, +host `TZ` independence, tampered-shard rebuilds, preflight store integrity, +single-UID rebuilds, interruption recovery, and no publication after malformed +input. + +```text +cargo clippy --manifest-path developer-simulation/runs/2026-08-06--caldav-recurrence/Cargo.toml --offline --all-targets -- -D warnings +``` + +Final result: passed with warnings denied. + +```text +developer-simulation/runs/2026-08-06--caldav-recurrence/target/debug/caldav-recurrence-prototype --help +``` + +Final result: passed; the usage text contains no stray help-marker character. + +```text +cargo run --manifest-path developer-simulation/runs/2026-08-06--caldav-recurrence/Cargo.toml --offline --bin generate_fixture -- developer-simulation/runs/2026-08-06--caldav-recurrence/evidence/workload-100zones +``` + +Observed: `generated 5000 masters and 2000000 candidate occurrences`. The +generated transition file contains 100 fixed transition tables plus +`FLOATING`. + +```text +cargo build --manifest-path developer-simulation/runs/2026-08-06--caldav-recurrence/Cargo.toml --offline --release --bin caldav-recurrence-prototype +``` + +Observed: release build passed. + +```text +/usr/bin/time -p developer-simulation/runs/2026-08-06--caldav-recurrence/target/release/caldav-recurrence-prototype \ + --events developer-simulation/runs/2026-08-06--caldav-recurrence/evidence/workload-100zones/workload-events.jsonl \ + --transitions developer-simulation/runs/2026-08-06--caldav-recurrence/evidence/workload-100zones/workload-zones.json \ + --from 2026-01-01T00:00:00Z --to 2027-06-01T00:00:00Z \ + --output developer-simulation/runs/2026-08-06--caldav-recurrence/evidence/workload-100zones-output-rerun.jsonl \ + --state-dir developer-simulation/runs/2026-08-06--caldav-recurrence/evidence/workload-100zones-state-rerun +``` + +Observed diagnostics: + +```json +{"events":5000,"occurrences":2000000,"rebuilt_uids":5000,"reused_uids":0,"removed_uids":0,"resumed":false,"elapsed_ms":3585,"peak_rss_bytes":17547264,"publication":"atomic-rename"} +``` + +Observed `/usr/bin/time -p`: `real 3.85` on this host. + +The output contained 2,000,000 lines. A second run with the same inputs reported +`rebuilt_uids:0`, `reused_uids:5000`, and `elapsed_ms:2923`; `cmp` found no byte +difference between the two rerun output files. Separate CLI runs under +`TZ=UTC` and `TZ=Pacific/Honolulu` also passed `cmp` with identical output. + +## Findings + +### F1 — Baseline has no stable occurrence identity or explicit local-time policy + +- Severity: high +- Confidence: high +- Category: baseline design defect, not a BogKit defect +- Reproduction: the baseline expands local wall time, converts to UTC, replaces + whole rows, and emits insertion order; it has no recurrence ID, fold/gap + policy, all-day type distinction, or unique output key. +- Smallest improvement: retain a civil recurrence identity, model all-day dates + separately, define supplied-table gap/fold rules, and publish by a stable key. + +### F2 — Fold fits keyed event replacement, but not calendar semantics + +- Severity: medium +- Confidence: high +- Category: component-fit finding, not a core defect +- Reproduction: public APIs provide keyed upsert/remove, snapshots, and + retraction, but no RRULE parser, civil-date arithmetic, transition lookup, + SQLite adapter, or artifact publisher. +- Smallest improvement: keep a narrow external calendar adapter and use + `KeyedStream` for event-master replacement/materialization only. Do not force + recurrence expansion into `Aggregate` or use `Bag` as a uniqueness index. + +### F3 — Public `starter` and `search` examples require an unavailable ESE model + +- Severity: low for this scenario; medium for onboarding +- Confidence: high +- Category: existing-example friction, not a prototype defect +- Reproduction: `cargo run -p starter` and `cargo run -p search` both fail in + ESE's model download build step when DNS/network access is unavailable; + `starter` also declares ESE even though its source does not use it. +- Smallest improvement: remove unused ESE/ANNy dependencies from `starter` and + make model-dependent examples fail with a clearer opt-in setup message. + +### F4 — Public `chat` cannot bind its demo listener in this sandbox + +- Severity: low +- Confidence: high +- Category: environment restriction, not a BogKit defect +- Reproduction: `cargo run -p chat` compiles, prints its startup URL, then + panics with `Operation not permitted` at `TcpListener::bind`. +- Smallest improvement: document a network-enabled run requirement or provide a + non-listening smoke mode. + +### F5 — Prototype does not yet preserve SQLite as the authoritative store + +- Severity: high for production acceptance; low for the standalone prototype + boundary +- Confidence: high +- Category: intentional prototype limitation, not a BogKit defect +- Reproduction: the CLI consumes event JSONL and stores its cache in Fold's + local fjall state; no SQLite dependency is present in `developer-simulation/runs/2026-08-06--caldav-recurrence/Cargo.toml`. +- Smallest improvement: add a read/transaction adapter over the existing SQLite + rows, keep Fold as a derived cache, and verify source-version plus publication + atomicity against the production schema. + +### F6 — External oracle and reference machine are absent + +- Severity: high unresolved acceptance risk +- Confidence: high +- Category: missing evidence, not a BogKit defect +- Reproduction: the assigned checkout contains no supplied 5,000-case oracle and + no reference-machine specification. +- Smallest improvement: run the prototype and the production candidate against + the supplied oracle, especially its exact gap/fold, override, and all-day + policies, then repeat the workload on the named reference machine. + +### F7 — Prototype intentionally covers a bounded recurrence subset + +- Severity: medium +- Confidence: high +- Category: prototype boundary +- Reproduction: unsupported RRULE fields, multiple rules, RDATE, ordinal + BYDAY, and time-zone-changing overrides are rejected. This is deliberate + validation, not silent approximation. +- Smallest improvement: add only the constructs present in the supplied oracle, + one at a time, with a differential test for each. + +## Skeptical review and final corrections + +The separate skeptical reviewer reproduced the DST, all-day, override, ordering, +incremental, tampered-shard, preflight, host-time-zone, and full-workload +claims. The review initially found five prototype correctness blockers: a +partial-day all-day omission, equivalent timed override IDs overwriting one +another, unseen overrides creating phantom occurrences, tampered shards being +trusted, and invalid expansion mutating the durable event store before +publication. It also found an incomplete trial README and a stray `+` in CLI +help. + +The coordinator fixed all six issues before archival: + +- all-day intersection now uses a half-open date range that includes a date + touched by a partial-day query endpoint; +- timed and all-day overrides are canonicalized before duplicate detection; +- overrides must correspond to a generated recurrence identity; +- every reusable shard carries and verifies byte length plus a SHA-256 digest; +- all desired events are expanded before the Fold event-store transaction; +- the trial README documents the supported schema, policies, commands, and + explicit durability boundary, and the help text was corrected. + +The corrected 10-test suite, strict formatting and Clippy, fresh release +workload, fingerprinted shard reuse, tamper regression, and independent host +time-zone comparisons all passed in the coordinator's reruns. The oracle, +production SQLite integration, standards completeness, and +filesystem or power-loss durability remain explicitly unverified. No BogKit +correctness defect was demonstrated; Fold remains a narrow event-master fit, +not a calendar-semantics or publication solution. + +## Decision audit + +1. The baseline was modeled before choosing a component. The problem is + primarily identity, local-time semantics, deterministic ordering, and + publication—not a generic aggregation problem. +2. `KeyedStream` was selected because its documented upsert retracts the old + value and its transaction/reopen behavior matches event-master edits. +3. `Table` was selected as the keyed sink because the event master has one value + per UID. A `Bag` was rejected because multiplicity is not occurrence + uniqueness. `Aggregate` was rejected because recurrence expansion is not an + invertible scalar accumulation. ANNy and ESE have no fit. +4. Calendar semantics remain outside Fold because no public BogKit component + covers the required rules or supplied transition tables. +5. The output is sharded and streamed so the 2-million-candidate fixture stays + within the measured memory budget while still allowing a one-UID rebuild. +6. No BogKit core, existing example, GitHub, or automation state was changed. + +## Unresolved uncertainty + +- The local gap/fold policy is explicit, but oracle agreement is unknown until + the supplied oracle is available. +- SQLite authority and integration with the deployed HTTP/CalDAV layer remain + untested by this standalone CLI. +- The workload and RSS numbers are from this environment, not the unavailable + named reference machine. +- Process interruption recovery was tested with the CLI's simulated interruption + hook. Full OS/power-loss durability of the parent-directory rename metadata + still needs a platform-specific verification. +- The prototype rejects malformed or unsupported input before publication, but + the complete production iCalendar parsing surface is intentionally outside + the prototype boundary. diff --git a/developer-simulation/runs/2026-08-06--caldav-recurrence/fixtures/smoke-events.jsonl b/developer-simulation/runs/2026-08-06--caldav-recurrence/fixtures/smoke-events.jsonl new file mode 100644 index 0000000..8838085 --- /dev/null +++ b/developer-simulation/runs/2026-08-06--caldav-recurrence/fixtures/smoke-events.jsonl @@ -0,0 +1,3 @@ +{"uid":"dst-meeting","kind":"timed","start":"2026-03-07T09:00:00","end":"2026-03-07T10:00:00","tzid":"America/New_York","rrule":"FREQ=DAILY;COUNT=4","exdate":["2026-03-09T09:00:00"],"overrides":[{"recurrence_id":"2026-03-08T09:00:00","start":"2026-03-08T11:00:00","end":"2026-03-08T12:00:00"},{"recurrence_id":"2026-03-10T09:00:00","status":"cancelled"}]} +{"uid":"all-day","kind":"all_day","start":"2026-03-08","end":"2026-03-09"} +{"uid":"floating","kind":"timed","start":"2026-03-08T09:00:00","end":"2026-03-08T10:00:00"} diff --git a/developer-simulation/runs/2026-08-06--caldav-recurrence/fixtures/smoke-zones.json b/developer-simulation/runs/2026-08-06--caldav-recurrence/fixtures/smoke-zones.json new file mode 100644 index 0000000..388e79b --- /dev/null +++ b/developer-simulation/runs/2026-08-06--caldav-recurrence/fixtures/smoke-zones.json @@ -0,0 +1,15 @@ +{ + "zones": { + "America/New_York": { + "initial_offset_seconds": -18000, + "transitions": [ + {"at_utc":"2026-03-08T07:00:00Z","offset_after_seconds":-14400}, + {"at_utc":"2026-11-01T06:00:00Z","offset_after_seconds":-18000} + ] + }, + "FLOATING": { + "initial_offset_seconds": 0, + "transitions": [] + } + } +} diff --git a/developer-simulation/runs/2026-08-06--caldav-recurrence/src/bin/generate_fixture.rs b/developer-simulation/runs/2026-08-06--caldav-recurrence/src/bin/generate_fixture.rs new file mode 100644 index 0000000..134b6e4 --- /dev/null +++ b/developer-simulation/runs/2026-08-06--caldav-recurrence/src/bin/generate_fixture.rs @@ -0,0 +1,50 @@ +use std::fmt::Write as _; +use std::fs::{self, File}; +use std::io::{BufWriter, Write}; +use std::path::PathBuf; + +use caldav_recurrence_prototype::Event; + +fn main() { + let output_dir = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("fixtures/generated")); + fs::create_dir_all(&output_dir).expect("create fixture directory"); + + let events_path = output_dir.join("workload-events.jsonl"); + let file = File::create(&events_path).expect("create workload events"); + let mut writer = BufWriter::new(file); + for index in 0..5_000 { + let event = Event { + uid: format!("workload-{index:04}"), + kind: "timed".to_string(), + start: "2026-01-01T09:00:00Z".to_string(), + end: "2026-01-01T10:00:00Z".to_string(), + tzid: Some("UTC".to_string()), + rrule: Some("FREQ=DAILY;COUNT=400".to_string()), + exdate: Vec::new(), + overrides: Vec::new(), + }; + serde_json::to_writer(&mut writer, &event).expect("encode workload event"); + writer.write_all(b"\n").expect("write workload event"); + } + writer.flush().expect("flush workload events"); + + let mut zones = String::from("{\n \"zones\": {\n"); + for index in 0..100 { + if index > 0 { + zones.push_str(",\n"); + } + write!( + zones, + " \"Fixture/Zone{index:03}\": {{\"initial_offset_seconds\":0,\"transitions\":[]}}" + ) + .expect("format fixture zone"); + } + zones.push_str( + ",\n \"FLOATING\": {\"initial_offset_seconds\":0,\"transitions\":[]}\n }\n}\n", + ); + fs::write(output_dir.join("workload-zones.json"), zones).expect("write workload zones"); + println!("generated 5000 masters and 2000000 candidate occurrences"); +} diff --git a/developer-simulation/runs/2026-08-06--caldav-recurrence/src/lib.rs b/developer-simulation/runs/2026-08-06--caldav-recurrence/src/lib.rs new file mode 100644 index 0000000..e8bf746 --- /dev/null +++ b/developer-simulation/runs/2026-08-06--caldav-recurrence/src/lib.rs @@ -0,0 +1,1594 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use chrono::{ + DateTime, Datelike, Days, Duration, FixedOffset, NaiveDate, NaiveDateTime, SecondsFormat, Utc, + Weekday, +}; +use fold::pipeline::terminal; +use fold::stream::KeyedStream; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use sha2::{Digest, Sha256}; + +pub type EventStore = KeyedStream>; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Event { + pub uid: String, + pub kind: String, + pub start: String, + pub end: String, + #[serde(default)] + pub tzid: Option, + #[serde(default)] + pub rrule: Option, + #[serde(default)] + pub exdate: Vec, + #[serde(default)] + pub overrides: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Override { + pub recurrence_id: String, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub start: Option, + #[serde(default)] + pub end: Option, + #[serde(default)] + pub tzid: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Edit { + pub uid: String, + #[serde(default)] + pub delete: bool, + #[serde(default)] + pub event: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct TransitionFile { + zones: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize)] +struct ZoneInput { + initial_offset_seconds: i32, + #[serde(default)] + transitions: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct TransitionInput { + at_utc: String, + offset_after_seconds: i32, +} + +#[derive(Debug, Clone)] +struct Transition { + at_utc: i64, + offset_before: i32, + offset_after: i32, +} + +#[derive(Debug, Clone)] +struct Zone { + initial_offset: i32, + transitions: Vec, +} + +#[derive(Debug, Clone)] +struct ZoneBook { + zones: BTreeMap, +} + +impl ZoneBook { + fn parse(raw: &[u8]) -> Result { + let input: TransitionFile = serde_json::from_slice(raw) + .map_err(|_| "transition file is not valid JSON".to_string())?; + if input.zones.is_empty() { + return Err("transition file has no zones".to_string()); + } + + let mut zones = BTreeMap::new(); + for (name, zone_input) in input.zones { + if name.is_empty() || name.len() > 128 { + return Err("transition file has an invalid zone name".to_string()); + } + validate_offset(zone_input.initial_offset_seconds)?; + let mut transitions = Vec::with_capacity(zone_input.transitions.len()); + let mut previous_at = None; + let mut previous_offset = zone_input.initial_offset_seconds; + for transition in zone_input.transitions { + let at_utc = parse_utc_seconds(&transition.at_utc) + .map_err(|_| "transition file has an invalid transition instant".to_string())?; + validate_offset(transition.offset_after_seconds)?; + if previous_at.is_some_and(|value| at_utc <= value) { + return Err("transition table is not strictly ordered".to_string()); + } + transitions.push(Transition { + at_utc, + offset_before: previous_offset, + offset_after: transition.offset_after_seconds, + }); + previous_at = Some(at_utc); + previous_offset = transition.offset_after_seconds; + } + zones.insert( + name, + Zone { + initial_offset: zone_input.initial_offset_seconds, + transitions, + }, + ); + } + Ok(ZoneBook { zones }) + } + + fn require(&self, name: &str) -> Result<&Zone, String> { + if name == "UTC" { + return Err( + "UTC is implicit and must not be supplied as a transition zone".to_string(), + ); + } + self.zones + .get(name) + .ok_or_else(|| "event references a zone absent from the transition file".to_string()) + } +} + +impl Zone { + fn offset_at(&self, utc_seconds: i64) -> i32 { + let mut low = 0usize; + let mut high = self.transitions.len(); + while low < high { + let middle = (low + high) / 2; + if self.transitions[middle].at_utc <= utc_seconds { + low = middle + 1; + } else { + high = middle; + } + } + if low == 0 { + self.initial_offset + } else { + self.transitions[low - 1].offset_after + } + } + + fn local_to_utc(&self, local: NaiveDateTime) -> Result { + let local_seconds = local.and_utc().timestamp(); + let mut offsets = Vec::with_capacity(6); + push_unique(&mut offsets, self.initial_offset); + push_unique(&mut offsets, self.offset_at(local_seconds)); + + // Only transitions near the local wall time can create a gap or fold. + // This keeps expansion independent of the number of historical rows in + // a supplied table while still considering both sides of a transition. + let lower = local_seconds.saturating_sub(172_800); + let upper = local_seconds.saturating_add(172_800); + let mut index = self.first_transition_at_or_after(lower); + if index > 0 { + let transition = &self.transitions[index - 1]; + push_unique(&mut offsets, transition.offset_before); + push_unique(&mut offsets, transition.offset_after); + } + while index < self.transitions.len() && self.transitions[index].at_utc <= upper { + let transition = &self.transitions[index]; + push_unique(&mut offsets, transition.offset_before); + push_unique(&mut offsets, transition.offset_after); + index += 1; + } + + let mut candidates = Vec::new(); + for offset in offsets { + let candidate = local_seconds - i64::from(offset); + if self.offset_at(candidate) == offset { + candidates.push(candidate); + } + } + candidates.sort_unstable(); + candidates.dedup(); + if let Some(earliest) = candidates.first() { + // A fold has two valid instants. Choosing the earlier one is + // deterministic and corresponds to the pre-transition side. + return Ok(*earliest); + } + + for transition in &self.transitions { + if transition.offset_after > transition.offset_before { + let gap_start = transition.at_utc + i64::from(transition.offset_before); + let gap_end = transition.at_utc + i64::from(transition.offset_after); + if (gap_start..gap_end).contains(&local_seconds) { + // Shift a nonexistent wall time forward by the gap. + return Ok(local_seconds - i64::from(transition.offset_before)); + } + } + } + Err("local time is not representable by the supplied transition table".to_string()) + } + + fn first_transition_at_or_after(&self, value: i64) -> usize { + let mut low = 0usize; + let mut high = self.transitions.len(); + while low < high { + let middle = (low + high) / 2; + if self.transitions[middle].at_utc < value { + low = middle + 1; + } else { + high = middle; + } + } + low + } +} + +fn push_unique(values: &mut Vec, value: i32) { + if !values.contains(&value) { + values.push(value); + } +} + +fn validate_offset(value: i32) -> Result<(), String> { + if !(-86_400..=86_400).contains(&value) { + Err("transition table contains an invalid UTC offset".to_string()) + } else { + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TimeMode { + Utc, + Zone(String), +} + +#[derive(Debug, Clone)] +enum EventPlan { + Timed { + uid: String, + start: NaiveDateTime, + end: NaiveDateTime, + mode: TimeMode, + rule: Option, + exdates: BTreeSet, + overrides: BTreeMap, + }, + AllDay { + uid: String, + start: NaiveDate, + end: NaiveDate, + rule: Option, + exdates: BTreeSet, + overrides: BTreeMap, + }, +} + +#[derive(Debug, Clone)] +struct OverridePlan { + cancelled: bool, + timed_start: Option, + timed_end: Option, + all_day_start: Option, + all_day_end: Option, +} + +#[derive(Debug, Clone)] +struct Rule { + frequency: Frequency, + interval: i64, + count: Option, + until: Option, + byday: Vec, + bymonthday: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Frequency { + Daily, + Weekly, + Monthly, +} + +#[derive(Debug, Clone)] +enum Until { + Date(NaiveDate), + DateTime(NaiveDateTime), +} + +#[derive(Debug, Clone)] +struct Window { + from_utc: i64, + to_utc: i64, + from_date: NaiveDate, + to_date_exclusive: NaiveDate, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Occurrence { + pub uid: String, + pub recurrence_id: String, + pub kind: String, + pub start: String, + pub end: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Manifest { + version: u32, + context_hash: u64, + shards: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ShardMeta { + event_hash: u64, + shard: String, + #[serde(default)] + byte_length: u64, + #[serde(default)] + content_hash: String, +} + +#[derive(Debug, Clone)] +pub struct Config { + pub events: PathBuf, + pub transitions: PathBuf, + pub from: String, + pub to: String, + pub output: PathBuf, + pub state_dir: PathBuf, + pub edits: Option, + pub crash_after_uid: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RunResult { + pub events: usize, + pub occurrences: usize, + pub rebuilt_uids: usize, + pub reused_uids: usize, + pub removed_uids: usize, + pub resumed: bool, + pub elapsed_ms: u128, + pub peak_rss_bytes: Option, + pub publication: &'static str, +} + +pub fn run(config: &Config) -> Result { + let started = Instant::now(); + let event_values = read_jsonl::(&config.events, "events")?; + let transition_raw = + fs::read(&config.transitions).map_err(|_| "could not read transition file".to_string())?; + let zones = ZoneBook::parse(&transition_raw)?; + let window = parse_window(&config.from, &config.to)?; + + let mut desired = BTreeMap::new(); + for event in event_values { + if desired.insert(event.uid.clone(), event).is_some() { + return Err("events contain a duplicate UID".to_string()); + } + } + if let Some(edits_path) = &config.edits { + let edits = read_jsonl::(edits_path, "edits")?; + let mut edit_uids = BTreeSet::new(); + for edit in edits { + if edit.uid.is_empty() || !edit_uids.insert(edit.uid.clone()) { + return Err("edits contain an invalid or duplicate UID".to_string()); + } + if edit.delete == edit.event.is_some() { + return Err("each edit must be exactly one of delete or replacement".to_string()); + } + if let Some(event) = edit.event { + if event.uid != edit.uid { + return Err("edit UID does not match replacement event".to_string()); + } + build_plan(&event, &zones)?; + desired.insert(edit.uid, event); + } else { + desired.remove(&edit.uid); + } + } + } + + // Validate expansion before touching the durable event store. If a + // recurrence cannot be expanded, the previous authoritative snapshot and + // published output remain unchanged. + for event in desired.values() { + let plan = build_plan(event, &zones)?; + expand_plan(&plan, &window, &zones)?; + } + + fs::create_dir_all(&config.state_dir) + .map_err(|_| "could not create state directory".to_string())?; + if let Some(parent) = config.output.parent() { + fs::create_dir_all(parent).map_err(|_| "could not create output directory".to_string())?; + } + let shard_dir = config.state_dir.join("shards"); + fs::create_dir_all(&shard_dir).map_err(|_| "could not create shard directory".to_string())?; + + let store_path = config.state_dir.join("event-store"); + let mut store = EventStore::new(&store_path, terminal::Table::new("events")); + let existing_uids = store.rtx(|table| table.iter().map(|(uid, _)| uid).collect::>()); + let mut removed_uids = 0; + store.wtx(|tx| { + for uid in &existing_uids { + if !desired.contains_key(uid) { + tx.remove(uid); + removed_uids += 1; + } + } + for (uid, event) in &desired { + tx.upsert(uid, event); + } + }); + store.checkpoint(); + + let mut current_events = store.rtx(|table| table.iter().collect::>()); + current_events.sort_by(|a, b| a.0.cmp(&b.0)); + + let manifest_path = config.state_dir.join("manifest.json"); + let previous_manifest = read_manifest(&manifest_path)?; + let context_hash = hash_context(&transition_raw, &config.from, &config.to); + let resumed = previous_manifest.is_some(); + let mut next_manifest = Manifest { + version: 1, + context_hash, + shards: BTreeMap::new(), + }; + let mut publication = PublicationWriter::new(&config.output)?; + let mut rebuilt_uids = 0; + let mut reused_uids = 0; + let mut occurrence_count = 0; + + for (uid, event) in ¤t_events { + let event_hash = hash_bytes(&serde_json::to_vec(event).unwrap_or_default()); + let shard_name = format!("{}.jsonl", hex(uid.as_bytes())); + let shard_path = shard_dir.join(&shard_name); + let reusable = previous_manifest.as_ref().is_some_and(|manifest| { + manifest.version == 1 + && manifest.context_hash == context_hash + && manifest.shards.get(uid).is_some_and(|meta| { + meta.event_hash == event_hash && verify_shard_metadata(&shard_path, meta) + }) + }); + + let occurrences = if reusable { + reused_uids += 1; + let occurrences = read_occurrences(&shard_path)?; + validate_shard(&occurrences, uid)?; + publication.append_shard(&shard_path)?; + occurrences + } else { + rebuilt_uids += 1; + let plan = build_plan(event, &zones)?; + let occurrences = expand_plan(&plan, &window, &zones)?; + validate_shard(&occurrences, uid)?; + write_shard_and_append(&shard_path, &occurrences, &mut publication)?; + if config.crash_after_uid.as_deref() == Some(uid.as_str()) { + return Err("simulated interruption after one shard commit".to_string()); + } + occurrences + }; + + let (byte_length, content_hash) = fingerprint_file(&shard_path)?; + + next_manifest.shards.insert( + uid.clone(), + ShardMeta { + event_hash, + shard: shard_name, + byte_length, + content_hash, + }, + ); + occurrence_count += occurrences.len(); + } + + publication.finish()?; + atomic_write_json(&manifest_path, &next_manifest)?; + + Ok(RunResult { + events: current_events.len(), + occurrences: occurrence_count, + rebuilt_uids, + reused_uids, + removed_uids, + resumed, + elapsed_ms: started.elapsed().as_millis(), + peak_rss_bytes: process_max_rss_bytes(), + publication: "atomic-rename", + }) +} + +#[cfg(unix)] +fn process_max_rss_bytes() -> Option { + let mut usage = std::mem::MaybeUninit::::zeroed(); + // SAFETY: getrusage initializes the rusage structure when it returns 0. + let result = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; + if result != 0 { + return None; + } + // macOS reports bytes; Linux and the other Unix targets report KiB. + let raw = u64::try_from(unsafe { usage.assume_init() }.ru_maxrss).ok()?; + #[cfg(target_os = "linux")] + { + raw.checked_mul(1024) + } + #[cfg(not(target_os = "linux"))] + { + Some(raw) + } +} + +#[cfg(not(unix))] +fn process_max_rss_bytes() -> Option { + None +} + +fn read_jsonl(path: &Path, label: &str) -> Result, String> { + let file = File::open(path).map_err(|_| format!("could not read {label} input"))?; + let reader = BufReader::new(file); + let mut values = Vec::new(); + for (line_number, line) in reader.lines().enumerate() { + let line = line.map_err(|_| format!("could not read {label} input"))?; + if line.trim().is_empty() { + continue; + } + values.push(serde_json::from_str(&line).map_err(|_| { + format!( + "{label} input has malformed JSON at line {}", + line_number + 1 + ) + })?); + } + Ok(values) +} + +fn parse_window(from: &str, to: &str) -> Result { + let from_utc = + parse_utc_seconds(from).map_err(|_| "query start is not RFC3339 UTC".to_string())?; + let to_utc = parse_utc_seconds(to).map_err(|_| "query end is not RFC3339 UTC".to_string())?; + if from_utc >= to_utc { + return Err("query window must have a positive duration".to_string()); + } + let from_date = DateTime::::from_timestamp(from_utc, 0) + .ok_or_else(|| "query start is out of range".to_string())? + .date_naive(); + let to_date = DateTime::::from_timestamp(to_utc, 0) + .ok_or_else(|| "query end is out of range".to_string())? + .date_naive(); + let to_date_exclusive = if to_utc.rem_euclid(86_400) == 0 { + to_date + } else { + to_date + .checked_add_days(Days::new(1)) + .ok_or_else(|| "query end date is out of range".to_string())? + }; + Ok(Window { + from_utc, + to_utc, + from_date, + to_date_exclusive, + }) +} + +fn build_plan(event: &Event, zones: &ZoneBook) -> Result { + if event.uid.is_empty() || event.uid.len() > 512 || event.uid.chars().any(char::is_control) { + return Err("event has an invalid UID".to_string()); + } + let rule = event.rrule.as_deref().map(parse_rule).transpose()?; + match event.kind.as_str() { + "timed" => build_timed_plan(event, zones, rule), + "all_day" => build_all_day_plan(event, rule), + _ => Err("event kind is unsupported".to_string()), + } +} + +fn build_timed_plan( + event: &Event, + zones: &ZoneBook, + rule: Option, +) -> Result { + let mode = match event.tzid.as_deref() { + Some("UTC") => TimeMode::Utc, + Some(name) => { + zones.require(name)?; + TimeMode::Zone(name.to_string()) + } + None => { + zones.require("FLOATING")?; + TimeMode::Zone("FLOATING".to_string()) + } + }; + let start = parse_timed_for_mode(&event.start, &mode)?; + let end = parse_timed_for_mode(&event.end, &mode)?; + if start >= end { + return Err("timed event end must be after start".to_string()); + } + + let mut exdates = BTreeSet::new(); + for value in &event.exdate { + let parsed = parse_timed_for_mode(value, &mode)?; + exdates.insert(format_recurrence_id(parsed, &mode)); + } + let mut overrides = BTreeMap::new(); + for value in &event.overrides { + let recurrence_id = canonical_timed_id(&value.recurrence_id, &mode)?; + if overrides.contains_key(&recurrence_id) { + return Err("event has duplicate occurrence overrides".to_string()); + } + let status = value.status.as_deref().unwrap_or("confirmed"); + if status != "confirmed" && status != "cancelled" { + return Err("occurrence override status is unsupported".to_string()); + } + if value.tzid.as_deref().is_some_and(|tzid| { + Some(tzid) + != match &mode { + TimeMode::Utc => Some("UTC"), + TimeMode::Zone(name) => Some(name.as_str()), + } + }) { + return Err("occurrence override changes time zone".to_string()); + } + if status == "cancelled" { + if value.start.is_some() || value.end.is_some() { + return Err("cancelled override must not have start or end".to_string()); + } + overrides.insert( + recurrence_id, + OverridePlan { + cancelled: true, + timed_start: None, + timed_end: None, + all_day_start: None, + all_day_end: None, + }, + ); + } else { + let start_value = value + .start + .as_deref() + .ok_or_else(|| "replacement override is missing start".to_string())?; + let end_value = value + .end + .as_deref() + .ok_or_else(|| "replacement override is missing end".to_string())?; + let start_value = parse_timed_for_mode(start_value, &mode)?; + let end_value = parse_timed_for_mode(end_value, &mode)?; + if start_value >= end_value { + return Err("replacement override end must be after start".to_string()); + } + overrides.insert( + recurrence_id, + OverridePlan { + cancelled: false, + timed_start: Some(start_value), + timed_end: Some(end_value), + all_day_start: None, + all_day_end: None, + }, + ); + } + } + Ok(EventPlan::Timed { + uid: event.uid.clone(), + start, + end, + mode, + rule, + exdates, + overrides, + }) +} + +fn build_all_day_plan(event: &Event, rule: Option) -> Result { + if event.tzid.is_some() { + return Err("all-day event must not carry a TZID".to_string()); + } + let start = parse_date(&event.start)?; + let end = parse_date(&event.end)?; + if start >= end { + return Err("all-day event end must be after start".to_string()); + } + let mut exdates = BTreeSet::new(); + for value in &event.exdate { + exdates.insert(parse_date(value)?.format("%Y-%m-%d").to_string()); + } + let mut overrides = BTreeMap::new(); + for value in &event.overrides { + let recurrence_id = parse_date(&value.recurrence_id)? + .format("%Y-%m-%d") + .to_string(); + if overrides.contains_key(&recurrence_id) { + return Err("event has duplicate occurrence overrides".to_string()); + } + let status = value.status.as_deref().unwrap_or("confirmed"); + if status != "confirmed" && status != "cancelled" { + return Err("occurrence override status is unsupported".to_string()); + } + if value.tzid.is_some() { + return Err("all-day occurrence override must not carry a TZID".to_string()); + } + if status == "cancelled" { + if value.start.is_some() || value.end.is_some() { + return Err("cancelled override must not have start or end".to_string()); + } + overrides.insert( + recurrence_id, + OverridePlan { + cancelled: true, + timed_start: None, + timed_end: None, + all_day_start: None, + all_day_end: None, + }, + ); + } else { + let replacement_start = parse_date( + value + .start + .as_deref() + .ok_or_else(|| "replacement override is missing start".to_string())?, + )?; + let replacement_end = parse_date( + value + .end + .as_deref() + .ok_or_else(|| "replacement override is missing end".to_string())?, + )?; + if replacement_start >= replacement_end { + return Err("replacement override end must be after start".to_string()); + } + overrides.insert( + recurrence_id, + OverridePlan { + cancelled: false, + timed_start: None, + timed_end: None, + all_day_start: Some(replacement_start), + all_day_end: Some(replacement_end), + }, + ); + } + } + Ok(EventPlan::AllDay { + uid: event.uid.clone(), + start, + end, + rule, + exdates, + overrides, + }) +} + +fn parse_rule(raw: &str) -> Result { + if raw.is_empty() { + return Err("recurrence rule is empty".to_string()); + } + let mut fields = BTreeMap::new(); + for part in raw.split(';') { + let (key, value) = part + .split_once('=') + .ok_or_else(|| "recurrence rule has a malformed field".to_string())?; + let key = key.to_ascii_uppercase(); + if fields.insert(key, value.to_string()).is_some() { + return Err("recurrence rule has a duplicate field".to_string()); + } + } + let frequency = match fields.remove("FREQ").as_deref() { + Some("DAILY") => Frequency::Daily, + Some("WEEKLY") => Frequency::Weekly, + Some("MONTHLY") => Frequency::Monthly, + _ => return Err("recurrence frequency is unsupported or missing".to_string()), + }; + let interval = fields.remove("INTERVAL").map_or(Ok(1), |value| { + value + .parse::() + .map_err(|_| "recurrence interval is invalid".to_string()) + })?; + if interval <= 0 { + return Err("recurrence interval must be positive".to_string()); + } + let count = fields + .remove("COUNT") + .map(|value| { + value + .parse::() + .map_err(|_| "recurrence count is invalid".to_string()) + }) + .transpose()?; + if count == Some(0) { + return Err("recurrence count must be positive".to_string()); + } + let until = fields + .remove("UNTIL") + .map(|value| parse_until(&value)) + .transpose()?; + let byday = fields + .remove("BYDAY") + .map(|value| parse_byday(&value)) + .transpose()? + .unwrap_or_default(); + let bymonthday = fields + .remove("BYMONTHDAY") + .map(|value| parse_bymonthday(&value)) + .transpose()? + .unwrap_or_default(); + if !fields.is_empty() { + return Err("recurrence rule contains an unsupported field".to_string()); + } + if frequency != Frequency::Weekly && !byday.is_empty() { + return Err("BYDAY is supported only for weekly rules".to_string()); + } + if frequency != Frequency::Monthly && !bymonthday.is_empty() { + return Err("BYMONTHDAY is supported only for monthly rules".to_string()); + } + Ok(Rule { + frequency, + interval, + count, + until, + byday, + bymonthday, + }) +} + +fn parse_until(value: &str) -> Result { + if let Ok(date) = NaiveDate::parse_from_str(value, "%Y%m%d") { + return Ok(Until::Date(date)); + } + if let Ok(date) = NaiveDate::parse_from_str(value, "%Y-%m-%d") { + return Ok(Until::Date(date)); + } + let value = value.trim_end_matches('Z'); + let datetime = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S") + .or_else(|_| NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S")) + .map_err(|_| "recurrence UNTIL is invalid".to_string())?; + Ok(Until::DateTime(datetime)) +} + +fn parse_byday(value: &str) -> Result, String> { + let mut days = Vec::new(); + for token in value.split(',') { + let day = match token { + "MO" => Weekday::Mon, + "TU" => Weekday::Tue, + "WE" => Weekday::Wed, + "TH" => Weekday::Thu, + "FR" => Weekday::Fri, + "SA" => Weekday::Sat, + "SU" => Weekday::Sun, + _ => return Err("recurrence BYDAY is invalid".to_string()), + }; + if days.contains(&day) { + return Err("recurrence BYDAY has a duplicate day".to_string()); + } + days.push(day); + } + days.sort_by_key(|day| day.num_days_from_monday()); + Ok(days) +} + +fn parse_bymonthday(value: &str) -> Result, String> { + let mut days = Vec::new(); + for token in value.split(',') { + let day = token + .parse::() + .map_err(|_| "recurrence BYMONTHDAY is invalid".to_string())?; + if day == 0 || !(-31..=31).contains(&day) { + return Err("recurrence BYMONTHDAY is out of range".to_string()); + } + if days.contains(&day) { + return Err("recurrence BYMONTHDAY has a duplicate day".to_string()); + } + days.push(day); + } + days.sort_unstable(); + Ok(days) +} + +fn parse_date(value: &str) -> Result { + NaiveDate::parse_from_str(value, "%Y-%m-%d") + .or_else(|_| NaiveDate::parse_from_str(value, "%Y%m%d")) + .map_err(|_| "date value is invalid".to_string()) +} + +fn parse_timed_for_mode(value: &str, mode: &TimeMode) -> Result { + let absolute = DateTime::parse_from_rfc3339(value).ok(); + match (mode, absolute) { + (TimeMode::Utc, Some(value)) => Ok(value.with_timezone(&Utc).naive_utc()), + (TimeMode::Utc, None) => NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") + .map_err(|_| "UTC time value is invalid".to_string()), + (TimeMode::Zone(_), Some(_)) => { + Err("zoned time must not carry a numeric UTC offset".to_string()) + } + (TimeMode::Zone(_), None) => NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") + .map_err(|_| "local time value is invalid".to_string()), + } +} + +fn canonical_timed_id(value: &str, mode: &TimeMode) -> Result { + let parsed = parse_timed_for_mode(value, mode)?; + Ok(format_recurrence_id(parsed, mode)) +} + +fn format_recurrence_id(value: NaiveDateTime, mode: &TimeMode) -> String { + let base = value.format("%Y-%m-%dT%H:%M:%S").to_string(); + if *mode == TimeMode::Utc { + format!("{base}Z") + } else { + base + } +} + +fn parse_utc_seconds(value: &str) -> Result { + let parsed: DateTime = DateTime::parse_from_rfc3339(value).map_err(|_| ())?; + Ok(parsed.with_timezone(&Utc).timestamp()) +} + +fn expand_plan( + plan: &EventPlan, + window: &Window, + zones: &ZoneBook, +) -> Result, String> { + match plan { + EventPlan::Timed { + uid, + start, + end, + mode, + rule, + exdates, + overrides, + } => { + let candidates = + timed_candidates(*start, *end, mode, rule.as_ref(), window, overrides)?; + let mut output = Vec::new(); + let mut seen = BTreeSet::new(); + for (recurrence_id, candidate_start, candidate_end) in candidates { + if !seen.insert(recurrence_id.clone()) { + return Err( + "recurrence rule produced a duplicate occurrence identity".to_string() + ); + } + if exdates.contains(&recurrence_id) { + continue; + } + let (candidate_start, candidate_end) = match overrides.get(&recurrence_id) { + Some(override_plan) if override_plan.cancelled => continue, + Some(override_plan) => ( + override_plan + .timed_start + .ok_or_else(|| "timed replacement is incomplete".to_string())?, + override_plan + .timed_end + .ok_or_else(|| "timed replacement is incomplete".to_string())?, + ), + None => (candidate_start, candidate_end), + }; + let start_utc = to_utc(candidate_start, mode, zones)?; + let end_utc = to_utc(candidate_end, mode, zones)?; + if end_utc <= start_utc { + return Err( + "occurrence end is not after start after time-zone conversion".to_string(), + ); + } + if start_utc < window.to_utc && end_utc > window.from_utc { + output.push(Occurrence { + uid: uid.clone(), + recurrence_id, + kind: "timed".to_string(), + start: format_utc(start_utc)?, + end: format_utc(end_utc)?, + }); + } + } + validate_override_identities(&seen, overrides)?; + output.sort_by(|a, b| a.recurrence_id.cmp(&b.recurrence_id)); + Ok(output) + } + EventPlan::AllDay { + uid, + start, + end, + rule, + exdates, + overrides, + } => { + let candidates = all_day_candidates(*start, *end, rule.as_ref(), window, overrides)?; + let mut output = Vec::new(); + let mut seen = BTreeSet::new(); + for (recurrence_id, candidate_start, candidate_end) in candidates { + if !seen.insert(recurrence_id.clone()) { + return Err( + "recurrence rule produced a duplicate occurrence identity".to_string() + ); + } + if exdates.contains(&recurrence_id) { + continue; + } + let (candidate_start, candidate_end) = match overrides.get(&recurrence_id) { + Some(override_plan) if override_plan.cancelled => continue, + Some(override_plan) => ( + override_plan + .all_day_start + .ok_or_else(|| "all-day replacement is incomplete".to_string())?, + override_plan + .all_day_end + .ok_or_else(|| "all-day replacement is incomplete".to_string())?, + ), + None => (candidate_start, candidate_end), + }; + if candidate_start < window.to_date_exclusive && candidate_end > window.from_date { + output.push(Occurrence { + uid: uid.clone(), + recurrence_id, + kind: "all_day".to_string(), + start: candidate_start.format("%Y-%m-%d").to_string(), + end: candidate_end.format("%Y-%m-%d").to_string(), + }); + } + } + validate_override_identities(&seen, overrides)?; + output.sort_by(|a, b| a.recurrence_id.cmp(&b.recurrence_id)); + Ok(output) + } + } +} + +fn timed_candidates( + start: NaiveDateTime, + end: NaiveDateTime, + mode: &TimeMode, + rule: Option<&Rule>, + window: &Window, + overrides: &BTreeMap, +) -> Result, String> { + let Some(rule) = rule else { + return Ok(vec![(format_recurrence_id(start, mode), start, end)]); + }; + let duration = end - start; + let mut hard_stop = window.to_utc.saturating_add(172_800); + for id in overrides.keys() { + let value = parse_timed_for_mode(id, mode)?; + hard_stop = hard_stop.max(value.and_utc().timestamp().saturating_add(172_800)); + } + match rule.frequency { + Frequency::Daily => { + let mut output = Vec::new(); + let mut date = start.date(); + let mut accepted = 0u64; + loop { + let candidate = date.and_time(start.time()); + if candidate.and_utc().timestamp() > hard_stop { + break; + } + if until_exceeded(rule.until.as_ref(), candidate, false) { + break; + } + if rule.byday.is_empty() || rule.byday.contains(&candidate.weekday()) { + accepted += 1; + output.push(( + format_recurrence_id(candidate, mode), + candidate, + candidate + duration, + )); + if rule.count.is_some_and(|count| accepted >= count) { + break; + } + } + date = add_days(date, rule.interval)?; + } + Ok(output) + } + Frequency::Weekly => { + let first_monday = add_days( + start.date(), + -(i64::from(start.date().weekday().num_days_from_monday())), + )?; + let weekdays = if rule.byday.is_empty() { + vec![start.date().weekday()] + } else { + rule.byday.clone() + }; + let mut output = Vec::new(); + let mut week_index = 0i64; + let mut accepted = 0u64; + loop { + let week_start = add_days(first_monday, week_index.saturating_mul(7))?; + for weekday in &weekdays { + let candidate_date = + add_days(week_start, i64::from(weekday.num_days_from_monday()))?; + let candidate = candidate_date.and_time(start.time()); + if candidate < start { + continue; + } + if candidate.and_utc().timestamp() > hard_stop { + return Ok(output); + } + if until_exceeded(rule.until.as_ref(), candidate, false) { + return Ok(output); + } + accepted += 1; + output.push(( + format_recurrence_id(candidate, mode), + candidate, + candidate + duration, + )); + if rule.count.is_some_and(|count| accepted >= count) { + return Ok(output); + } + } + week_index = week_index + .checked_add(rule.interval) + .ok_or_else(|| "recurrence is too large".to_string())?; + } + } + Frequency::Monthly => { + let mut month_index = 0i64; + let mut output = Vec::new(); + let mut accepted = 0u64; + loop { + let (year, month) = add_months(start.year(), start.month(), month_index)?; + let days = if rule.bymonthday.is_empty() { + vec![i32::try_from(start.day()).unwrap_or(0)] + } else { + rule.bymonthday.clone() + }; + for day in days { + let Some(candidate_date) = month_day(year, month, day) else { + continue; + }; + let candidate = candidate_date.and_time(start.time()); + if candidate < start { + continue; + } + if candidate.and_utc().timestamp() > hard_stop { + return Ok(output); + } + if until_exceeded(rule.until.as_ref(), candidate, false) { + return Ok(output); + } + accepted += 1; + output.push(( + format_recurrence_id(candidate, mode), + candidate, + candidate + duration, + )); + if rule.count.is_some_and(|count| accepted >= count) { + return Ok(output); + } + } + month_index = month_index + .checked_add(rule.interval) + .ok_or_else(|| "recurrence is too large".to_string())?; + } + } + } +} + +fn all_day_candidates( + start: NaiveDate, + end: NaiveDate, + rule: Option<&Rule>, + window: &Window, + overrides: &BTreeMap, +) -> Result, String> { + let Some(rule) = rule else { + return Ok(vec![(start.format("%Y-%m-%d").to_string(), start, end)]); + }; + let duration = end - start; + let mut hard_stop = window.to_date_exclusive; + for id in overrides.keys() { + hard_stop = hard_stop.max(parse_date(id)?); + } + let mut output = Vec::new(); + let mut accepted = 0u64; + match rule.frequency { + Frequency::Daily => { + let mut date = start; + loop { + if date > hard_stop { + break; + } + if until_exceeded( + rule.until.as_ref(), + date.and_time(chrono::NaiveTime::MIN), + true, + ) { + break; + } + if rule.byday.is_empty() || rule.byday.contains(&date.weekday()) { + accepted += 1; + output.push(( + date.format("%Y-%m-%d").to_string(), + date, + date.checked_add_signed(duration) + .ok_or_else(|| "date range is too large".to_string())?, + )); + if rule.count.is_some_and(|count| accepted >= count) { + break; + } + } + date = add_days(date, rule.interval)?; + } + } + Frequency::Weekly => { + let first_monday = + add_days(start, -(i64::from(start.weekday().num_days_from_monday())))?; + let weekdays = if rule.byday.is_empty() { + vec![start.weekday()] + } else { + rule.byday.clone() + }; + let mut week_index = 0i64; + 'weeks: loop { + let week_start = add_days(first_monday, week_index.saturating_mul(7))?; + for weekday in &weekdays { + let date = add_days(week_start, i64::from(weekday.num_days_from_monday()))?; + if date < start { + continue; + } + if date > hard_stop { + break 'weeks; + } + if until_exceeded( + rule.until.as_ref(), + date.and_time(chrono::NaiveTime::MIN), + true, + ) { + break 'weeks; + } + accepted += 1; + output.push(( + date.format("%Y-%m-%d").to_string(), + date, + date.checked_add_signed(duration) + .ok_or_else(|| "date range is too large".to_string())?, + )); + if rule.count.is_some_and(|count| accepted >= count) { + break 'weeks; + } + } + week_index = week_index + .checked_add(rule.interval) + .ok_or_else(|| "recurrence is too large".to_string())?; + } + } + Frequency::Monthly => { + let mut month_index = 0i64; + loop { + let (year, month) = add_months(start.year(), start.month(), month_index)?; + let days = if rule.bymonthday.is_empty() { + vec![i32::try_from(start.day()).unwrap_or(0)] + } else { + rule.bymonthday.clone() + }; + for day in days { + let Some(date) = month_day(year, month, day) else { + continue; + }; + if date < start { + continue; + } + if date > hard_stop { + return Ok(output); + } + if until_exceeded( + rule.until.as_ref(), + date.and_time(chrono::NaiveTime::MIN), + true, + ) { + return Ok(output); + } + accepted += 1; + output.push(( + date.format("%Y-%m-%d").to_string(), + date, + date.checked_add_signed(duration) + .ok_or_else(|| "date range is too large".to_string())?, + )); + if rule.count.is_some_and(|count| accepted >= count) { + return Ok(output); + } + } + month_index = month_index + .checked_add(rule.interval) + .ok_or_else(|| "recurrence is too large".to_string())?; + } + } + } + Ok(output) +} + +fn validate_override_identities( + seen: &BTreeSet, + overrides: &BTreeMap, +) -> Result<(), String> { + if overrides + .keys() + .any(|recurrence_id| !seen.contains(recurrence_id)) + { + return Err("occurrence override does not match a generated recurrence".to_string()); + } + Ok(()) +} + +fn until_exceeded(until: Option<&Until>, candidate: NaiveDateTime, _all_day: bool) -> bool { + match until { + Some(Until::Date(date)) => candidate.date() > *date, + Some(Until::DateTime(value)) => candidate > *value, + None => false, + } +} + +fn to_utc(value: NaiveDateTime, mode: &TimeMode, zones: &ZoneBook) -> Result { + match mode { + TimeMode::Utc => Ok(value.and_utc().timestamp()), + TimeMode::Zone(name) => zones.require(name)?.local_to_utc(value), + } +} + +fn format_utc(seconds: i64) -> Result { + DateTime::::from_timestamp(seconds, 0) + .map(|value| value.to_rfc3339_opts(SecondsFormat::Secs, true)) + .ok_or_else(|| "UTC occurrence is out of range".to_string()) +} + +fn add_days(date: NaiveDate, days: i64) -> Result { + if days >= 0 { + date.checked_add_days(Days::new(days as u64)) + } else { + date.checked_sub_days(Days::new(days.unsigned_abs())) + } + .ok_or_else(|| "recurrence date is out of range".to_string()) +} + +fn add_months(year: i32, month: u32, delta: i64) -> Result<(i32, u32), String> { + let base = i64::from(year) + .checked_mul(12) + .and_then(|value| value.checked_add(i64::from(month) - 1)) + .and_then(|value| value.checked_add(delta)) + .ok_or_else(|| "recurrence month is out of range".to_string())?; + let year = base.div_euclid(12); + let month = base.rem_euclid(12) + 1; + Ok(( + i32::try_from(year).map_err(|_| "recurrence year is out of range".to_string())?, + u32::try_from(month).map_err(|_| "recurrence month is out of range".to_string())?, + )) +} + +fn month_day(year: i32, month: u32, requested: i32) -> Option { + let first = NaiveDate::from_ymd_opt(year, month, 1)?; + let next = if month == 12 { + NaiveDate::from_ymd_opt(year + 1, 1, 1)? + } else { + NaiveDate::from_ymd_opt(year, month + 1, 1)? + }; + let last_day = i32::try_from((next - Duration::days(1)).day()).ok()?; + let day = if requested > 0 { + requested + } else { + last_day + requested + 1 + }; + if !(1..=last_day).contains(&day) { + None + } else { + first.with_day(u32::try_from(day).ok()?) + } +} + +fn read_occurrences(path: &Path) -> Result, String> { + read_jsonl(path, "state shard") +} + +fn verify_shard_metadata(path: &Path, metadata: &ShardMeta) -> bool { + fingerprint_file(path) + .map(|(byte_length, content_hash)| { + byte_length == metadata.byte_length && content_hash == metadata.content_hash + }) + .unwrap_or(false) +} + +fn fingerprint_file(path: &Path) -> Result<(u64, String), String> { + let mut file = File::open(path).map_err(|_| "could not read state shard".to_string())?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 16 * 1024]; + let mut byte_length = 0u64; + loop { + let count = file + .read(&mut buffer) + .map_err(|_| "could not read state shard".to_string())?; + if count == 0 { + break; + } + byte_length = byte_length + .checked_add(u64::try_from(count).map_err(|_| "state shard is too large".to_string())?) + .ok_or_else(|| "state shard is too large".to_string())?; + hasher.update(&buffer[..count]); + } + Ok((byte_length, hex(&hasher.finalize()))) +} + +fn validate_shard(occurrences: &[Occurrence], uid: &str) -> Result<(), String> { + let mut identities = BTreeSet::new(); + for occurrence in occurrences { + if occurrence.uid != uid || !identities.insert(occurrence.recurrence_id.clone()) { + return Err("state contains a duplicate or unexpected occurrence identity".to_string()); + } + } + Ok(()) +} + +fn read_manifest(path: &Path) -> Result, String> { + if !path.exists() { + return Ok(None); + } + let bytes = + fs::read(path).map_err(|_| "could not read materialization manifest".to_string())?; + match serde_json::from_slice(&bytes) { + Ok(manifest) => Ok(Some(manifest)), + Err(_) => Ok(None), + } +} + +struct PublicationWriter { + final_path: PathBuf, + temp_path: PathBuf, + writer: BufWriter, +} + +impl PublicationWriter { + fn new(final_path: &Path) -> Result { + let temp_path = temp_path(final_path); + remove_stale_temp(&temp_path)?; + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + .map_err(|_| "could not create temporary publication".to_string())?; + Ok(Self { + final_path: final_path.to_path_buf(), + temp_path, + writer: BufWriter::new(file), + }) + } + + fn append_shard(&mut self, path: &Path) -> Result<(), String> { + let shard = + File::open(path).map_err(|_| "could not read materialization shard".to_string())?; + for line in BufReader::new(shard).lines() { + let line = line.map_err(|_| "could not read materialization shard".to_string())?; + self.writer + .write_all(line.as_bytes()) + .and_then(|_| self.writer.write_all(b"\n")) + .map_err(|_| "could not write JSONL publication".to_string())?; + } + Ok(()) + } + + fn finish(self) -> Result<(), String> { + let mut writer = self.writer; + writer + .flush() + .map_err(|_| "could not flush JSONL publication".to_string())?; + writer + .into_inner() + .map_err(|_| "could not finalize JSONL publication".to_string())? + .sync_all() + .map_err(|_| "could not sync JSONL publication".to_string())?; + fs::rename(&self.temp_path, &self.final_path) + .map_err(|_| "could not atomically publish JSONL output".to_string()) + } +} + +fn write_shard_and_append( + path: &Path, + occurrences: &[Occurrence], + publication: &mut PublicationWriter, +) -> Result<(), String> { + let temp = temp_path(path); + remove_stale_temp(&temp)?; + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|_| "could not create temporary materialization shard".to_string())?; + let mut writer = BufWriter::new(file); + for occurrence in occurrences { + let encoded = serde_json::to_vec(occurrence) + .map_err(|_| "could not encode materialization shard".to_string())?; + writer + .write_all(&encoded) + .and_then(|_| writer.write_all(b"\n")) + .map_err(|_| "could not write materialization shard".to_string())?; + publication + .writer + .write_all(&encoded) + .and_then(|_| publication.writer.write_all(b"\n")) + .map_err(|_| "could not write JSONL publication".to_string())?; + } + writer + .flush() + .map_err(|_| "could not flush materialization shard".to_string())?; + fs::rename(&temp, path).map_err(|_| "could not publish materialization shard".to_string()) +} + +fn atomic_write_json(path: &Path, value: &T) -> Result<(), String> { + let temp = temp_path(path); + remove_stale_temp(&temp)?; + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|_| "could not create temporary manifest".to_string())?; + let mut writer = BufWriter::new(file); + serde_json::to_writer_pretty(&mut writer, value) + .map_err(|_| "could not encode materialization manifest".to_string())?; + writer + .write_all(b"\n") + .map_err(|_| "could not write materialization manifest".to_string())?; + writer + .flush() + .map_err(|_| "could not flush materialization manifest".to_string())?; + writer + .into_inner() + .map_err(|_| "could not finalize materialization manifest".to_string())? + .sync_all() + .map_err(|_| "could not sync materialization manifest".to_string())?; + fs::rename(&temp, path) + .map_err(|_| "could not atomically publish materialization manifest".to_string()) +} + +fn remove_stale_temp(path: &Path) -> Result<(), String> { + if path.exists() { + fs::remove_file(path) + .map_err(|_| "could not remove a stale temporary publication".to_string())?; + } + Ok(()) +} + +fn temp_path(path: &Path) -> PathBuf { + PathBuf::from(format!("{}.tmp-{}", path.display(), std::process::id())) +} + +fn hash_context(transitions: &[u8], from: &str, to: &str) -> u64 { + let mut data = Vec::with_capacity(transitions.len() + from.len() + to.len() + 2); + data.extend_from_slice(transitions); + data.push(0); + data.extend_from_slice(from.as_bytes()); + data.push(0); + data.extend_from_slice(to.as_bytes()); + hash_bytes(&data) +} + +fn hash_bytes(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf29ce484222325u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(char::from(DIGITS[usize::from(byte >> 4)])); + output.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + output +} + +pub fn format_diagnostics(result: &RunResult) -> String { + serde_json::to_string(result).unwrap_or_else(|_| "{\"publication\":\"failed\"}".to_string()) +} diff --git a/developer-simulation/runs/2026-08-06--caldav-recurrence/src/main.rs b/developer-simulation/runs/2026-08-06--caldav-recurrence/src/main.rs new file mode 100644 index 0000000..580bd7d --- /dev/null +++ b/developer-simulation/runs/2026-08-06--caldav-recurrence/src/main.rs @@ -0,0 +1,91 @@ +use std::path::PathBuf; + +use caldav_recurrence_prototype::{Config, format_diagnostics, run}; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + print_help(); + return; + } + let config = match parse_args(&args[1..]) { + Ok(config) => config, + Err(error) => { + eprintln!("error: {error}"); + std::process::exit(2); + } + }; + match run(&config) { + Ok(result) => println!("{}", format_diagnostics(&result)), + Err(error) => { + eprintln!("error: {error}"); + std::process::exit(1); + } + } +} + +fn parse_args(args: &[String]) -> Result { + let mut events = None; + let mut transitions = None; + let mut from = None; + let mut to = None; + let mut output = None; + let mut state_dir = None; + let mut edits = None; + let mut crash_after_uid = None; + + let mut index = 0; + while index < args.len() { + let name = &args[index]; + let value = args + .get(index + 1) + .ok_or_else(|| format!("missing value for {name}"))?; + let slot = match name.as_str() { + "--events" => &mut events, + "--transitions" => &mut transitions, + "--from" => &mut from, + "--to" => &mut to, + "--output" => &mut output, + "--state-dir" => &mut state_dir, + "--edits" => &mut edits, + "--crash-after" => &mut crash_after_uid, + _ => return Err(format!("unknown argument {name}")), + }; + if slot.is_some() { + return Err(format!("argument {name} was supplied twice")); + } + *slot = Some(value.clone()); + index += 2; + } + + Ok(Config { + events: required_path(events, "--events")?, + transitions: required_path(transitions, "--transitions")?, + from: required_string(from, "--from")?, + to: required_string(to, "--to")?, + output: required_path(output, "--output")?, + state_dir: required_path(state_dir, "--state-dir")?, + edits: edits.map(PathBuf::from), + crash_after_uid, + }) +} + +fn required_path(value: Option, name: &str) -> Result { + value + .map(PathBuf::from) + .ok_or_else(|| format!("missing required argument {name}")) +} + +fn required_string(value: Option, name: &str) -> Result { + value.ok_or_else(|| format!("missing required argument {name}")) +} + +fn print_help() { + println!( + "caldav-recurrence-prototype\n\n\ + Usage:\n caldav-recurrence-prototype --events EVENTS.jsonl --transitions ZONES.json \\\n --from RFC3339 --to RFC3339 --output OCCURRENCES.jsonl --state-dir STATE [--edits EDITS.jsonl]\n\n\ + Event JSONL supports timed and all_day events, DAILY/WEEKLY/MONTHLY\n\ + rules, EXDATE values, and confirmed/cancelled occurrence overrides.\n\ + Diagnostics are emitted as one non-sensitive JSON object on stdout." + ); +} diff --git a/developer-simulation/runs/2026-08-06--caldav-recurrence/tests/behavior.rs b/developer-simulation/runs/2026-08-06--caldav-recurrence/tests/behavior.rs new file mode 100644 index 0000000..917a896 --- /dev/null +++ b/developer-simulation/runs/2026-08-06--caldav-recurrence/tests/behavior.rs @@ -0,0 +1,507 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use caldav_recurrence_prototype::{Config, Event, Occurrence, Override, run}; +use fold::pipeline::terminal; + +struct FixtureDir(PathBuf); + +impl FixtureDir { + fn new(label: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "bogkit-caldav-{label}-{}-{stamp}", + std::process::id() + )); + fs::create_dir_all(&path).expect("fixture directory"); + Self(path) + } + + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } + + fn write_events(&self, name: &str, events: &[Event]) -> PathBuf { + let path = self.path(name); + let body = events + .iter() + .map(|event| serde_json::to_string(event).expect("event JSON")) + .collect::>() + .join("\n"); + fs::write(&path, format!("{body}\n")).expect("events"); + path + } + + fn write_edits(&self, name: &str, edits: &[serde_json::Value]) -> PathBuf { + let path = self.path(name); + let body = edits + .iter() + .map(|edit| serde_json::to_string(edit).expect("edit JSON")) + .collect::>() + .join("\n"); + fs::write(&path, format!("{body}\n")).expect("edits"); + path + } + + fn zones(&self) -> PathBuf { + let path = self.path("zones.json"); + fs::write( + &path, + r#"{ + "zones": { + "America/New_York": { + "initial_offset_seconds": -18000, + "transitions": [ + {"at_utc":"2026-03-08T07:00:00Z","offset_after_seconds":-14400}, + {"at_utc":"2026-11-01T06:00:00Z","offset_after_seconds":-18000} + ] + }, + "FLOATING": { + "initial_offset_seconds": 0, + "transitions": [] + } + } +} +"#, + ) + .expect("zones"); + path + } + + fn config(&self, events: &Path, output: &str, state: &str) -> Config { + Config { + events: events.to_path_buf(), + transitions: self.zones(), + from: "2026-03-07T00:00:00Z".to_string(), + to: "2026-03-12T00:00:00Z".to_string(), + output: self.path(output), + state_dir: self.path(state), + edits: None, + crash_after_uid: None, + } + } +} + +impl Drop for FixtureDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn timed(uid: &str, start: &str, end: &str, tzid: Option<&str>, rrule: Option<&str>) -> Event { + Event { + uid: uid.to_string(), + kind: "timed".to_string(), + start: start.to_string(), + end: end.to_string(), + tzid: tzid.map(str::to_string), + rrule: rrule.map(str::to_string), + exdate: Vec::new(), + overrides: Vec::new(), + } +} + +#[test] +fn dst_exceptions_all_day_and_floating_times_are_deterministic() { + let fixture = FixtureDir::new("semantics"); + let mut recurring = timed( + "meeting", + "2026-03-07T09:00:00", + "2026-03-07T10:00:00", + Some("America/New_York"), + Some("FREQ=DAILY;COUNT=4"), + ); + recurring.exdate.push("2026-03-09T09:00:00".to_string()); + recurring.overrides.push(Override { + recurrence_id: "2026-03-08T09:00:00".to_string(), + status: None, + start: Some("2026-03-08T11:00:00".to_string()), + end: Some("2026-03-08T12:00:00".to_string()), + tzid: None, + }); + recurring.overrides.push(Override { + recurrence_id: "2026-03-10T09:00:00".to_string(), + status: Some("cancelled".to_string()), + start: None, + end: None, + tzid: None, + }); + + let gap = timed( + "gap", + "2026-03-08T02:30:00", + "2026-03-08T04:00:00", + Some("America/New_York"), + None, + ); + let fold = timed( + "fold", + "2026-11-01T01:30:00", + "2026-11-01T02:30:00", + Some("America/New_York"), + None, + ); + let all_day = Event { + uid: "all-day".to_string(), + kind: "all_day".to_string(), + start: "2026-03-08".to_string(), + end: "2026-03-09".to_string(), + tzid: None, + rrule: None, + exdate: Vec::new(), + overrides: Vec::new(), + }; + let floating = timed( + "floating", + "2026-03-08T09:00:00", + "2026-03-08T10:00:00", + None, + None, + ); + let events = vec![recurring, gap, fold, all_day, floating]; + let events_path = fixture.write_events("events.jsonl", &events); + let mut config = fixture.config(&events_path, "out.jsonl", "state"); + config.to = "2026-11-02T00:00:00Z".to_string(); + let result = run(&config).expect("prototype run"); + assert_eq!(result.events, 5); + + let lines = fs::read_to_string(&config.output) + .expect("output") + .lines() + .map(|line| serde_json::from_str::(line).expect("occurrence JSON")) + .collect::>(); + let find = |uid: &str, recurrence_id: &str| { + lines + .iter() + .find(|occurrence| occurrence.uid == uid && occurrence.recurrence_id == recurrence_id) + .expect("occurrence") + }; + assert_eq!( + find("gap", "2026-03-08T02:30:00").start, + "2026-03-08T07:30:00Z" + ); + assert_eq!( + find("fold", "2026-11-01T01:30:00").start, + "2026-11-01T05:30:00Z" + ); + assert_eq!(find("all-day", "2026-03-08").start, "2026-03-08"); + assert_eq!( + find("floating", "2026-03-08T09:00:00").start, + "2026-03-08T09:00:00Z" + ); + assert!(lines.iter().all(|occurrence| { + !(occurrence.uid == "meeting" && occurrence.recurrence_id == "2026-03-09T09:00:00") + })); + assert!(lines.iter().all(|occurrence| { + !(occurrence.uid == "meeting" && occurrence.recurrence_id == "2026-03-10T09:00:00") + })); + assert!(lines.windows(2).all(|pair| { + (pair[0].uid.as_str(), pair[0].recurrence_id.as_str()) + < (pair[1].uid.as_str(), pair[1].recurrence_id.as_str()) + })); +} + +#[test] +fn input_order_and_host_environment_do_not_change_output() { + let fixture = FixtureDir::new("ordering"); + let events = vec![ + timed( + "z", + "2026-03-07T09:00:00Z", + "2026-03-07T10:00:00Z", + Some("UTC"), + Some("FREQ=DAILY;COUNT=2"), + ), + timed( + "a", + "2026-03-07T11:00:00Z", + "2026-03-07T12:00:00Z", + Some("UTC"), + Some("FREQ=DAILY;COUNT=2"), + ), + ]; + let first_path = fixture.write_events("first.jsonl", &events); + let reversed = vec![events[1].clone(), events[0].clone()]; + let second_path = fixture.write_events("second.jsonl", &reversed); + let first = fixture.config(&first_path, "first.out", "first.state"); + let second = fixture.config(&second_path, "second.out", "second.state"); + run(&first).expect("first run"); + run(&second).expect("second run"); + assert_eq!( + fs::read(&first.output).expect("first output"), + fs::read(&second.output).expect("second output") + ); +} + +#[test] +fn one_event_edit_rebuilds_one_shard_and_resume_recovers_after_interruption() { + let fixture = FixtureDir::new("incremental"); + let events = vec![ + timed( + "keep", + "2026-03-07T09:00:00Z", + "2026-03-07T10:00:00Z", + Some("UTC"), + Some("FREQ=DAILY;COUNT=2"), + ), + timed( + "change", + "2026-03-07T11:00:00Z", + "2026-03-07T12:00:00Z", + Some("UTC"), + Some("FREQ=DAILY;COUNT=2"), + ), + ]; + let events_path = fixture.write_events("events.jsonl", &events); + let edits_path = fixture.write_edits( + "edits.jsonl", + &[serde_json::json!({ + "uid":"change", + "event": timed("change", "2026-03-07T15:00:00Z", "2026-03-07T16:00:00Z", Some("UTC"), Some("FREQ=DAILY;COUNT=2")) + })], + ); + let mut config = fixture.config(&events_path, "out.jsonl", "state"); + run(&config).expect("initial run"); + let keep_shard = fs::read_dir(config.state_dir.join("shards")) + .expect("shards") + .map(|entry| entry.expect("entry").path()) + .find(|path| { + fs::read_to_string(path) + .expect("shard") + .contains("\"keep\"") + }) + .expect("keep shard"); + let keep_before = fs::read(&keep_shard).expect("keep before"); + let old_output = fs::read(&config.output).expect("old output"); + + config.edits = Some(edits_path.clone()); + config.crash_after_uid = Some("change".to_string()); + assert!(run(&config).is_err()); + assert_eq!( + fs::read(&config.output).expect("output after crash"), + old_output + ); + + config.crash_after_uid = None; + let result = run(&config).expect("resumed run"); + assert_eq!(result.rebuilt_uids, 1); + assert_eq!(result.reused_uids, 1); + assert_eq!(fs::read(&keep_shard).expect("keep after"), keep_before); + assert!( + fs::read_to_string(&config.output) + .expect("new output") + .contains("2026-03-07T15:00:00Z") + ); +} + +#[test] +fn malformed_input_does_not_publish_or_modify_existing_output() { + let fixture = FixtureDir::new("validation"); + let valid = fixture.write_events( + "valid.jsonl", + &[timed( + "one", + "2026-03-07T09:00:00Z", + "2026-03-07T10:00:00Z", + Some("UTC"), + None, + )], + ); + let mut config = fixture.config(&valid, "out.jsonl", "state"); + run(&config).expect("valid run"); + let before = fs::read(&config.output).expect("before"); + + let invalid = fixture.path("invalid.jsonl"); + fs::write(&invalid, "{\"uid\":\"broken\",\"kind\":\"timed\"}\n").expect("invalid input"); + config.events = invalid; + assert!(run(&config).is_err()); + assert_eq!(fs::read(&config.output).expect("after"), before); +} + +#[test] +fn weekly_and_monthly_rules_use_calendar_steps() { + let fixture = FixtureDir::new("calendar-steps"); + let weekly = timed( + "weekly", + "2026-03-02T09:00:00", + "2026-03-02T10:00:00", + Some("America/New_York"), + Some("FREQ=WEEKLY;BYDAY=MO,WE;COUNT=4"), + ); + let monthly = timed( + "monthly", + "2026-01-15T09:00:00Z", + "2026-01-15T10:00:00Z", + Some("UTC"), + Some("FREQ=MONTHLY;BYMONTHDAY=15;COUNT=3"), + ); + let events = fixture.write_events("events.jsonl", &[weekly, monthly]); + let mut config = fixture.config(&events, "out.jsonl", "state"); + config.from = "2026-01-01T00:00:00Z".to_string(); + config.to = "2026-04-01T00:00:00Z".to_string(); + let result = run(&config).expect("calendar run"); + assert_eq!(result.occurrences, 7); + let output = fs::read_to_string(&config.output).expect("output"); + assert!(output.contains("2026-03-09T13:00:00Z")); + assert!(output.contains("2026-03-11T13:00:00Z")); + assert!(output.contains("2026-01-15T09:00:00Z")); + assert!(output.contains("2026-02-15T09:00:00Z")); + assert!(output.contains("2026-03-15T09:00:00Z")); +} + +#[test] +fn partial_day_query_includes_the_touched_all_day_date() { + let fixture = FixtureDir::new("all-day-window"); + let events = fixture.write_events( + "events.jsonl", + &[Event { + uid: "day".to_string(), + kind: "all_day".to_string(), + start: "2026-03-08".to_string(), + end: "2026-03-09".to_string(), + tzid: None, + rrule: None, + exdate: Vec::new(), + overrides: Vec::new(), + }], + ); + let mut config = fixture.config(&events, "out.jsonl", "state"); + config.from = "2026-03-08T00:00:00Z".to_string(); + config.to = "2026-03-08T12:00:00Z".to_string(); + let result = run(&config).expect("partial-day query"); + assert_eq!(result.occurrences, 1); + assert!( + fs::read_to_string(&config.output) + .expect("output") + .contains("\"recurrence_id\":\"2026-03-08\"") + ); +} + +#[test] +fn duplicate_canonical_override_ids_are_rejected() { + let fixture = FixtureDir::new("canonical-overrides"); + let mut event = timed( + "duplicate", + "2026-03-08T09:00:00Z", + "2026-03-08T10:00:00Z", + Some("UTC"), + Some("FREQ=DAILY;COUNT=2"), + ); + event.overrides = vec![ + Override { + recurrence_id: "2026-03-08T09:00:00Z".to_string(), + status: None, + start: Some("2026-03-08T11:00:00Z".to_string()), + end: Some("2026-03-08T12:00:00Z".to_string()), + tzid: None, + }, + Override { + recurrence_id: "2026-03-08T09:00:00+00:00".to_string(), + status: None, + start: Some("2026-03-08T13:00:00Z".to_string()), + end: Some("2026-03-08T14:00:00Z".to_string()), + tzid: None, + }, + ]; + let events = fixture.write_events("events.jsonl", &[event]); + let config = fixture.config(&events, "out.jsonl", "state"); + assert!(run(&config).is_err()); +} + +#[test] +fn override_for_an_unseen_recurrence_is_rejected() { + let fixture = FixtureDir::new("unseen-override"); + let mut event = timed( + "unseen", + "2026-03-08T09:00:00Z", + "2026-03-08T10:00:00Z", + Some("UTC"), + None, + ); + event.overrides.push(Override { + recurrence_id: "2026-03-09T09:00:00Z".to_string(), + status: None, + start: Some("2026-03-09T11:00:00Z".to_string()), + end: Some("2026-03-09T12:00:00Z".to_string()), + tzid: None, + }); + let events = fixture.write_events("events.jsonl", &[event]); + let config = fixture.config(&events, "out.jsonl", "state"); + assert!(run(&config).is_err()); +} + +#[test] +fn tampered_shards_are_rebuilt_before_reuse() { + let fixture = FixtureDir::new("shard-integrity"); + let event = timed( + "tamper", + "2026-03-08T09:00:00Z", + "2026-03-08T10:00:00Z", + Some("UTC"), + None, + ); + let events = fixture.write_events("events.jsonl", &[event]); + let config = fixture.config(&events, "out.jsonl", "state"); + run(&config).expect("initial run"); + let shard = fs::read_dir(config.state_dir.join("shards")) + .expect("shards") + .map(|entry| entry.expect("entry").path()) + .next() + .expect("one shard"); + fs::write( + &shard, + br#"{"uid":"tamper","recurrence_id":"2026-03-08T09:00:00Z","kind":"timed","start":"2030-01-01T00:00:00Z","end":"2030-01-01T01:00:00Z"} +"#, + ) + .expect("tampered shard"); + let result = run(&config).expect("rebuild run"); + assert_eq!(result.rebuilt_uids, 1); + assert_eq!(result.reused_uids, 0); + assert!( + fs::read_to_string(&config.output) + .expect("output") + .contains("2026-03-08T09:00:00Z") + ); +} + +#[test] +fn failed_expansion_does_not_mutate_the_durable_event_store() { + let fixture = FixtureDir::new("preflight-store"); + let valid_event = timed( + "stable", + "2026-03-08T09:00:00", + "2026-03-08T10:00:00", + Some("America/New_York"), + None, + ); + let valid = fixture.write_events("valid.jsonl", std::slice::from_ref(&valid_event)); + let mut config = fixture.config(&valid, "out.jsonl", "state"); + run(&config).expect("valid run"); + + let invalid_event = timed( + "stable", + "2026-03-08T02:30:00", + "2026-03-08T03:00:00", + Some("America/New_York"), + None, + ); + let invalid = fixture.write_events("invalid.jsonl", &[invalid_event]); + config.events = invalid; + assert!(run(&config).is_err()); + + let store = caldav_recurrence_prototype::EventStore::new( + config.state_dir.join("event-store"), + terminal::Table::new("events"), + ); + let stored_start = store.rtx(|table| { + table + .iter() + .find(|(uid, _)| uid == "stable") + .map(|(_, event)| event.start) + }); + assert_eq!(stored_start.as_deref(), Some("2026-03-08T09:00:00")); +} diff --git a/developer-simulation/runs/2026-08-06--http-cache-revalidation/Cargo.toml b/developer-simulation/runs/2026-08-06--http-cache-revalidation/Cargo.toml new file mode 100644 index 0000000..2950c8a --- /dev/null +++ b/developer-simulation/runs/2026-08-06--http-cache-revalidation/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "http-cache-revalidation" +version = "0.1.0" +edition = "2024" +publish = false diff --git a/developer-simulation/runs/2026-08-06--http-cache-revalidation/README.md b/developer-simulation/runs/2026-08-06--http-cache-revalidation/README.md new file mode 100644 index 0000000..88ec931 --- /dev/null +++ b/developer-simulation/runs/2026-08-06--http-cache-revalidation/README.md @@ -0,0 +1,125 @@ +# HTTP cache revalidation trial + +This is a standalone, dependency-free state-machine prototype for the assigned +cache scenario. It is intentionally not an HTTP parser, proxy, origin client, +distributed cache, CDN API, or production performance claim. + +## Decision + +The BogKit fit is partial but not sufficient for the acceptance-critical +prototype, so this trial uses no BogKit component at runtime. + +The public examples and Fold source show useful building blocks: + +- `Stream`/`KeyedStream` and `Table` provide durable, single-writer, + incrementally maintained records. +- `InvertedIndex` can maintain tag postings. +- `Ranked` can order records for a ranking-based eviction helper. +- `Retain` is a processing-time window based on a wall clock, not a logical + trace-time freshness/stale-if-error policy. +- The chat example's ingest thread makes the single-owner write model explicit; + it does not provide 32-worker per-key leases. + +None of those public surfaces atomically commits a Fold/fjall metadata +transaction together with a separate content-addressed body file. They also do +not supply per-key single-flight leases or tenant sequence-aware purge +semantics. Adding a custom BogKit node for those rules would make the trial a +new cache implementation rather than an evaluation of an existing component. + +## Baseline model + +The baseline is modeled first in `baseline_reproduction()` and is deliberately +limited to the stated behavior: + +| Baseline behavior | Reproduced consequence | Reference-model correction | +| --- | --- | --- | +| Key is method plus URL | Vary variants and tenants collide | Tenant, normalized method/URL, and Vary fingerprint form the key | +| Exact-URL invalidation | A tag purge leaves a tagged response servable | Tenant-scoped tag postings invalidate matching entries immediately | +| No per-key lease | Concurrent expiry starts two revalidations | One active lease per canonical key; later workers wait | +| Metadata/body writes are separate | A crash can expose an unverified body or delete an old referenced body | Journal phases are `Prepared`, `BodyCommitted`, and `MetadataCommitted`; the in-memory model rolls back or retains references | + +The baseline output is a failure reproducer, not a statement about an +unavailable production gateway. + +## Reference model + +The reference engine covers only the requested boundary: + +- canonical key normalization, including tenant and Vary fingerprint; +- fresh, expired, stale-if-error, miss, and revalidation decisions using + logical trace time; +- one active lease per key in a single-process serialized model and explicit + completion events; lease expiry, worker loss, and distributed concurrency are + outside the model; +- tenant tag indexes, duplicate purges, reordered purges, and purge fencing of + older revalidation results; +- digest/size/verification labels and modeled body/metadata commit points; +- in-memory reachability cleanup and deterministic LRU eviction under a byte + quota; no body bytes, filesystem deletion, SQLite transaction, fsync, or + process-restart behavior; +- SHA-256 identifiers and stable reason codes in output. + +The body is represented by a digest, size, and verification bit. No body bytes +are read, generated, or emitted, so the model cannot prove body-file deletion +safety. Origin responses are supplied by `origin` trace records. + +## Running it + +From the repository root: + +```console +cargo fmt --manifest-path developer-simulation/runs/2026-08-06--http-cache-revalidation/Cargo.toml -- --check +cargo test --manifest-path developer-simulation/runs/2026-08-06--http-cache-revalidation/Cargo.toml +cargo clippy --manifest-path developer-simulation/runs/2026-08-06--http-cache-revalidation/Cargo.toml --all-targets -- -D warnings +cargo run --manifest-path developer-simulation/runs/2026-08-06--http-cache-revalidation/Cargo.toml -- demo +cargo run --manifest-path developer-simulation/runs/2026-08-06--http-cache-revalidation/Cargo.toml -- run developer-simulation/runs/2026-08-06--http-cache-revalidation/demo.trace +``` + +The compact shape workload defaults to the stated 2,000,000 objects, +1,000,000 requests, and 100,000 purges: + +```console +cargo build --release --manifest-path developer-simulation/runs/2026-08-06--http-cache-revalidation/Cargo.toml +/usr/bin/time -l developer-simulation/target/release/http-cache-revalidation workload +``` + +The workload stores compact logical records and does not allocate URLs, body +bytes, tag postings, leases, or one output record per request. It is only a +memory/quota shape check; it is not semantic evidence for 2 million objects, +1 million requests, or 100,000 purge events. The semantic tests and trace demo +are the correctness evidence. + +## Trace format + +The parser is intentionally small and whitespace-delimited: + +```text +quota +blob verified|unverified +entry +origin error +origin not_modified +origin modified verified|unverified +request