Skip to content

feat(data): aprender-contrastive-data — deterministic, leakage-safe pair construction - #2702

Merged
noahgift merged 5 commits into
paiml:mainfrom
guyernest:contrastive-data
Aug 27, 2026
Merged

feat(data): aprender-contrastive-data — deterministic, leakage-safe pair construction#2702
noahgift merged 5 commits into
paiml:mainfrom
guyernest:contrastive-data

Conversation

@guyernest

Copy link
Copy Markdown
Contributor

aprender-contrastive-data — deterministic, leakage-safe pair construction

Contrastive/Siamese data construction as a general capability: class buckets,
balanced few-shot selection, bounded positive/negative pair sampling, typed split
roles, dataset fingerprints, and the cross-split leakage checks that make those
trustworthy.

Independent of #2618. This does not depend on aprender-core at all — its
whole dependency closure is 28 packages. Review and merge in either order.

Why a separate crate

Not folded into aprender-data: that crate carries arrow/parquet/zstd/s3/
streaming/federated weight which must not flow into a training crate or a
serverless consumer. SetFit is this crate's first consumer, not its owner.

The bytes boundary

The public API is bytes-in / bytes-out and typed values. No filesystem access, no
sockets, no path-shaped parameters — not even in tests. apr-cli owns every
filesystem adapter.

That is not stylistic. The destination for these artifacts is object storage,
where a manifest is an S3 object rather than a file; an API that speaks &Path
makes such a consumer a rewrite instead of a wrapper.

It is enforced, not asserted: make contrastive-data-boundary checks the
resolved dependency closure against a positive allowlist and bans
std::fs/std::net/std::path/Path/PathBuf under src/. Wired into tier3,
because a target outside the tiers is a target that stops being run.

Determinism

Every random decision is a pure function of its draw ordinal, from the
counter-based Philox generator in aprender-rand. Worker-count independence is
structural rather than tested-for: draw i cannot depend on how many draws
preceded it, because nothing precedes it.

Five typestate misuses are proven unrepresentable with trybuild compile-fail
cases and committed .stderr snapshots, rather than asserted in prose.

One fix to the gate itself

allowed-deps.txt listed provable-contracts-macros — the workspace dependency
key. The crate is aprender-contracts-macros (the key carries
package = "..."), and the crate name is what cargo tree prints, so the entry
could never match. The gate was RED from the commit that scaffolded it.

Corrected to the real package name. This is a name fix, not a widening — the
reviewed decision is unchanged, and the gate proved non-vacuous by catching it.

Verification

rc captured directly, never through a pipe:

cargo check  -p aprender-contrastive-data --all-targets     rc=0
cargo test   -p aprender-contrastive-data                   249 passed / 0 failed
cargo clippy -p aprender-contrastive-data --all-targets
             -- -D warnings                                 rc=0
make contrastive-data-boundary                              rc=0
cargo fmt --all -- --check                                  rc=0
pv validate contracts/contrastive-pair-protocol-v1.yaml     0 errors, 0 warnings

Note for whoever merges second

#2618 and this PR both add aprender-rand to [workspace.dependencies]. That
one line will conflict textually; everything else is disjoint. Whichever lands
second needs a one-line rebase, and I'm happy to do it.

🤖 Generated with Claude Code

guyernest and others added 2 commits August 25, 2026 06:50
Schema before implementation, as the repo does for phase contracts. Defines the
deterministic contrastive data protocol: class buckets, balanced few-shot
selection, bounded pair sampling, typed split roles, dataset fingerprints and
the cross-split leakage checks.

`contracts/aprender/binding.yaml` gains the 24 entries that bind its equations
to the modules landing in the next commit.

  pv validate contracts/contrastive-pair-protocol-v1.yaml   0 errors, 0 warnings

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…air construction

Contrastive/Siamese DATA CONSTRUCTION as a general capability: class buckets,
balanced few-shot selection, bounded positive/negative pair sampling, typed
split roles, dataset fingerprints, and the cross-split leakage checks that make
those trustworthy. SetFit is its first consumer, not its owner.

Deliberately a separate crate rather than part of aprender-data: that crate
carries arrow/parquet/zstd/s3/streaming/federated weight which must not flow
into a training crate or a serverless consumer.

BYTES BOUNDARY. The public API is bytes-in / bytes-out and typed values. No
filesystem access, no sockets, no path-shaped parameters — not even in tests;
apr-cli owns every filesystem adapter. The destination for these artifacts is
object storage, where a manifest is an S3 object, not a file: an API that speaks
`&Path` makes such a consumer a rewrite instead of a wrapper. The boundary is
ENFORCED, not asserted — `make contrastive-data-boundary` checks the resolved
dependency closure against a positive allowlist and bans std::fs/net/path
symbols under src/. Wired into tier3, because a target outside the tiers is a
target that stops being run.

DETERMINISM. Every random decision is a pure function of its draw ordinal, from
the counter-based Philox generator in aprender-rand. Worker-count independence
is structural rather than tested-for: draw i cannot depend on how many draws
preceded it, because nothing precedes it.

Five typestate misuses are proven unrepresentable with trybuild compile-fail
cases and committed .stderr snapshots, rather than asserted in prose.

ONE FIX TO THE GATE ITSELF. allowed-deps.txt listed `provable-contracts-macros`
— the workspace dependency KEY. The crate is `aprender-contracts-macros` (the
key carries `package = "..."`), and the crate name is what `cargo tree` prints,
so the entry could never match and the gate was RED from the commit that
scaffolded it. Corrected to the real package name. This is a name fix, not a
widening: the reviewed decision is unchanged, and the gate proved non-vacuous by
catching it.

Verified (rc captured directly, never through a pipe):
  cargo check  -p aprender-contrastive-data --all-targets    rc=0
  cargo test   -p aprender-contrastive-data                  249 passed, 0 failed
  cargo clippy -p aprender-contrastive-data --all-targets
               -- -D warnings                                rc=0
  make contrastive-data-boundary                             rc=0 (deps subset + symbol ban)
  cargo fmt --all -- --check                                 rc=0
  pv validate contracts/contrastive-pair-protocol-v1.yaml    0 errors, 0 warnings

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@noahgift

Copy link
Copy Markdown
Contributor

I will look tomorrow and merge

…osed

Merges 4aff459 (paiml#2618, same author) into this branch. paiml#2618 landed first and
appended 23 binding entries to the same region of contracts/aprender/binding.yaml,
so the merge needed resolving. Resolving it surfaced three further defects that
were NOT conflicts, plus one this PR would have taken red into CI.

1. contracts/aprender/binding.yaml — the 24 entries bound NOTHING, on this
   branch, before any merge.

   They were appended at end-of-file, below `critical_path:` (a Vec<String>),
   so every one parsed as a map inside that string list and the WHOLE file
   failed to load:

     pv audit contracts/contrastive-pair-protocol-v1.yaml \
       --binding contracts/aprender/binding.yaml     rc=1
     error: Failed to parse YAML: critical_path[41]: invalid type: map,
            expected a string at line 896 column 3

   This is trap 3 verbatim from paiml#2618's own comment block in this same file
   ("Entries must be inserted BEFORE critical_path:. Appending at end-of-file
   lands inside that list"). `pv validate` cannot see it — it reads the contract,
   not the binding — which is why the PR's verification block reported green.

   Both sides' entries now sit inside `bindings:`, ahead of `critical_path:`:

     pv audit contrastive  rc=0   24/24 bound, 24 implemented, no gaps
     pv audit setfit-apr   rc=0   15/15 bound, no gaps

   Note the resolution is NOT "keep both": keep-both leaves this branch's block
   below `critical_path:` and reproduces the same rc=1.

2. Cargo.toml — a SILENT duplicate key, worse than a conflict.

   Both branches add aprender-rand to [workspace.dependencies] at different
   textual positions, so git auto-merged BOTH and reported no conflict:

     cargo metadata  rc=101
     error: duplicate key `aprender-rand` ... Cargo.toml:305

   Dropped this branch's copy; paiml#2618's entry on main carries the fuller comment.

3. scripts/cascade-publish.sh — required CI check, red.

   aprender-contrastive-data is publishable (no `publish = false`) but was absent
   from TIERS[]. check_cascade_covers_all_crates.sh runs in ci.yml and is green
   on main, red with this branch. Its own message states why this matters: FINAL
   VERIFICATION iterates the same table, so an absent crate reads as success.
   Added to TIERS[2] — after its only two workspace deps, both TIER 1.

4. README.md — required CI check, red.

   check_readme_claims.sh counts crates and contracts against the tree. This
   branch adds one of each: 78 -> 79, 1796 -> 1797.

Verified on the merged tree, rc captured directly, never through a pipe:

  cargo metadata --no-deps                                rc=0  (79 members)
  cargo metadata --locked                                 rc=0
  cargo check --workspace                                 rc=0
  cargo test -p aprender-contrastive-data --all-targets   rc=0  (242 passed, 0 failed)
  cargo clippy -p aprender-contrastive-data --all-targets
        -- -D warnings                                    rc=0
  cargo fmt --all -- --check                              rc=0
  pv validate contrastive-pair-protocol-v1                rc=0
  pv audit contrastive / setfit --binding                 rc=0 / rc=0
  make contrastive-data-boundary                          rc=0
  scripts/check_cascade_covers_all_crates.sh              rc=0
  scripts/check_readme_claims.sh                          rc=0
  scripts/check_assertions_exclude.sh                     rc=0  (delta -10)
  77 of 79 ci.yml shell gates green before this commit; these two were the
  only failures, and both are green on main.

Also drops a stray duplicated "# Tier 4: CI/CD" comment this branch introduced
at Makefile:378/380.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov-commenter

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

…ing its own rows

D1. CanonicalDeclarations carries FOUR independent label maps — one per split, plus a
shared one — and nothing compared them.

`from_labeled_rows` validates each split's rows against that split's own `label_names`,
then passes the UNRELATED `decls.label_names` into the dataset fingerprint, the stored
map, and `SelectionPayload::label_names`. Declare the split maps ["none","against",
"favor"] and the shared map ["favor","against","none"] and every row validates, the
counts agree, `select` returns Ok, and `Selection::replay` PASSES — because replay
compares the payload against the same reversed map the fingerprint committed. Downstream
reads its class names from that payload, so every class-0 example is labelled "favor"
while the rows say "none". Nothing anywhere goes red.

No existing test could see it: `test_corpus::declarations` builds all four maps from one
`label_names()` helper, so the four can never disagree in the fixture.

The shorter-map variant is a broken round-trip rather than a relabelling: ClassBuckets
sizes from the train map while check_class_balance sizes from the payload map, so the
crate emits a manifest it cannot replay.

`check_label_maps_agree()` on both declaration types, called at the top of both ingest
doors BEFORE any row is read, naming the first divergent split in declaration order and
carrying both maps. The attested-bytes door already threads a single map and is untouched.

MUTATION-VERIFIED, not asserted. With the two calls replaced by a comment:

  cargo test -p aprender-contrastive-data --lib label_map
    gate armed     5 passed, 0 failed
    gate disarmed  2 passed, 4 FAILED

The 2 that survive are the control, `prepared_accepts_declarations_whose_label_maps_agree`,
which asserts the honest corpus STILL BUILDS. A gate is not proven by a test that reds
when it is removed; it is proven by that test plus one that would red if the gate started
refusing everything.

D3. The source half of `make contrastive-data-boundary` passed real filesystem writes and
TcpStream egress.

The ban was three literal substrings and two whole words. `use std::{fs, net::TcpStream};`
contains NONE of them — the text is `std::{fs` — and binds `fs` and `TcpStream` into scope
identically. This is not an exotic spelling: rustfmt.toml asks for
`imports_granularity = "Crate"`, so it is the spelling rustfmt PRODUCES here.

Verified against a compiled mutation, rc captured directly, never through a pipe: a module
doing fs::write and TcpStream::connect compiled rc=0 and the gate passed it rc=0.

scripts/lib/d04_grouped_std_import.awk accumulates each `use` statement to its terminating
`;`, so a multi-line group is tested as one string, then requires both a `std::` prefix and
an fs/net/path path SEGMENT — segment-anchored so `collections`, `fmt`, `Cow` and an
identifier like `pathological_case` cannot trip it. The same mutation is now rc=2.

The patterns ship a case table, because every miss this gate has had was found by a case
and none by review: 6 must-match and 6 must-not-match under tests/gate_cases/, run by the
gate itself on every invocation so a detector that silently stops matching fails loudly
instead of passing violations through. Both of the table's own failure modes induced:

  detector emptied     rc=2, "3 of 12 case(s) WRONG", naming each unflagged file
  case dir removed     rc=2, "would report PASS while checking nothing"

The must-not-match half is not symmetry for its own sake — it is the only thing that can
tell a working detector from one that flags everything.

D2. `cargo tree ... 2>&1` merged cargo's stderr into the file parsed as the dependency
closure.

cargo writes progress to stderr: `Blocking waiting for file lock on package cache`,
`Updating crates.io index`. `awk 'NF { print $1 }'` turns each into a phantom package name
and `comm -23` reports it as an unlisted dependency. This fired twice unprompted during
review — rc=2, offender `Blocking` — on a tree that was clean seconds earlier, so any
concurrent cargo reds the gate at random. The FAIL text says do not widen the allowlist,
which leaves a developer no lever except to add `Blocking` to allowed-deps.txt forever.

stderr now has its own sink; both files are shown when cargo tree actually fails.

Verified on this tree, rc captured directly:

  cargo test -p aprender-contrastive-data --all-targets   rc=0  247 passed, 0 failed
  cargo clippy -p aprender-contrastive-data --all-targets
        -- -D warnings                                    rc=0
  cargo fmt --all -- --check                              rc=0
  make contrastive-data-boundary                          rc=0  (incl. cases 12/12)
  pv audit contrastive --binding                          rc=0  24/24 bound
  all 79 ci.yml shell gates                               rc=0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@noahgift
noahgift enabled auto-merge August 27, 2026 07:59
…id so

FALSIFY-README-CRATE-001 (crates/aprender-core/tests/readme_contract.rs:254) asserts
every directory under crates/ carrying a Cargo.toml also carries a README.md. The new
crate carried neither, so the required workspace-test job failed:

  Integration tests ... failure
  FALSIFY-README-CRATE-001: Crates missing README.md: ["aprender-contrastive-data"]
  test result: FAILED. 14 passed; 1 failed

Note WHERE this was caught, because it is the interesting part. The 79 ci.yml shell
gates were all green on this tree, and so was `cargo test -p aprender-contrastive-data
--all-targets`. Neither could see it: the check lives in aprender-core's integration
target, reachable only from the hand-maintained `&&` chain at ci.yml:358. Sweeping the
shell gates and calling that "CI-equivalent" is the gap; this commit was verified by
extracting that chain verbatim from the workflow file and running it:

  bash <ci.yml:358 chain, extracted verbatim>   rc=0   33/33 suites ok

The README also satisfies FALSIFY-README-CRATE-002 (must contain `paiml/aprender`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@noahgift
noahgift added this pull request to the merge queue Aug 27, 2026
Merged via the queue into paiml:main with commit e039ed4 Aug 27, 2026
11 checks passed
noahgift pushed a commit that referenced this pull request Aug 27, 2026
…ld not see it

Build Book failed on this PR:

  FAIL: book/src/lib/setfit.md does not exist (aprender::setfit has no chapter)
  Coverage: 71/72 aprender-core public modules have a chapter (1 missing)
  FALSIFY-BOOK-LIB-PARITY-001: FAIL

The module came from #2618, not from this branch. It failed HERE because this
PR touches book/, which is the only thing that runs the gate.

THE PATH FILTER IS THE REAL DEFECT, and book.yml already documents it — about
the gate one line up:

  # FALSIFY-BOOK-CLI-PARITY-001 asserts every `apr` subcommand has a chapter.
  # Filtering on book/** alone meant it only ran when the BOOK changed, never
  # when the CLI gained a command — so `apr beat-run` shipped in #1995 with no
  # chapter and the gate stayed green for months, until an unrelated batch that
  # happened to touch book/ woke it. A parity gate must run when EITHER side
  # moves; watching only one of them is how it comes to certify nothing.

That fix added crates/apr-cli/src/commands_enum.rs to the paths. It was never
applied to FALSIFY-BOOK-LIB-PARITY-001, whose other side is aprender-core —
which appeared in this file only inside a comment. So the identical failure
recurred one gate down: a module shipped without a chapter, the gate stayed
green, and an unrelated batch touching book/ woke it. Same shape, same
workflow, same paragraph.

Fixed both halves:
  - book/src/lib/setfit.md written, listed in SUMMARY.md between serialization
    and showcase. It records that pair construction lives in
    aprender-contrastive-data (#2702), so the two crates' boundary is stated
    where a reader of either will find it.
  - crates/aprender-core/src/lib.rs added to the paths filter, in BOTH the push
    and pull_request blocks. The gate now runs when either side moves, which is
    what its own comment already said it must do.

MUTATION:
  remove book/src/lib/setfit.md   rc=1  RED
  restored                        rc=0  GREEN, 72/72 modules
  book.yml parses (yaml.safe_load)  OK
  cli_parity / example_block / linkcheck / readme_claims / claim_literals  rc=0

The last one matters: a new book page is a user-facing surface, so it goes
through the claim-literal guard this PR widened. It states no throughput number
and no comparator ratio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants