Name a reason instead of crashing, and stop the verdict depending on the host - #2
Name a reason instead of crashing, and stop the verdict depending on the host#2GiulioDER wants to merge 2 commits into
Conversation
b810874 to
858121c
Compare
|
#1 is merged, so this one is now conflicting exactly as you called it — it carries that commit too. I want this half as much as the first. The three that matter most to me, in order:
Rebase when you have time and I'll merge it. If you would rather hand it off, say the word and I'll rebase it myself with your authorship intact — it is your work either way. On the residuals you named: |
…depending on the host Rebased onto main after egnaro9#1, and reworked to address the revert: the two tests that induced a RecursionError built their fixture with json.loads('{"a":' * 3000), which raises inside the DECODER at a depth that varies by interpreter. They were green on 3.14 and red on the 3.11 CI leg, so the tests for a host-dependent defect were themselves host-dependent. Rather than move the fixture, both issuer-data traversals are now ITERATIVE, so there is no depth window left to be host-dependent about: - _todo_failures walks the whole manifest - _summary_outruns walks results.summary Order is preserved exactly (children pushed in reverse, popped in original order, depth-first as before), which the committed failure lists pin. The replacement tests build a 20000-deep structure in Python rather than through the decoder, so they exercise the traversal on every interpreter, and the backstop's append-not-replace property is now tested by INJECTING a RecursionError rather than by inducing one. The RecursionError backstop stays as defence in depth for traversals that are not iterative yet. It APPENDS: returning a fresh list would let an issuer delete every other reason, a smuggled unlisted-file included, just by padding a structure with depth. Crashes that escaped verify_bundle, each now a named reason: - non-dict elements in certlab verdicts, fleet rows, or a raw jsonl line - a scalar evidence / results.checks, since `5 or []` is 5 - an unhashable value reaching a dict key or set element. dict.get() RAISES on an unhashable key rather than returning its default. The guard is hashability, NOT a string requirement: SPEC 3.2 and 3.3 type none of suite/member/operator_id, so integer ids are legal evidence and two control tests hold that door open. - a non-string modeldrift `t`, previously coerced to "" without a reason - a non-list or mixed-type `fails` - a deeply nested manifest, and a top-level JSON array artifact Conflict resolutions worth naming: - crashkit severity: main's refusal of out-of-table labels supersedes the weigh-as-zero approach this branch carried, so main's version is kept. One addition: `x in {...}` and a set comprehension both RAISE on a list, so an unhashable severity escaped as a TypeError rather than as that named refusal. Hashability is now tested first. - verify_bundle: main's draft short-circuit is kept ahead of the backstop. An unbounded scan on an issuer-controlled field: - `re.sub(r"<[^>]+>", "", narr["html"])` rescans to end-of-string from every start position when no '>' follows. A CLEAN-verifying bundle cost 22.4s for 128k characters. The replacement is linear and reproduces the regex EXACTLY, verified over 200000 random strings, because SPEC 3.5 pins the committed narrative against this transform. Same input now takes 0.004s. Issuer text reaching the terminal, and the host-dependent verdict: - the replay block is echoed after the verdict line and was printed raw, so ANSI escapes could paint a forged PASS over a failing run. Escaping the control ranges is not sufficient on its own: it does not make text ENCODABLE, and the verifier's own verdict line carries an em dash, so on an ascii stdout the CLI died after the FAIL reasons and before the verdict. Bidi overrides and the Unicode separators are escaped too. - read_text/write_text used the platform codec, so a manifest carrying a valid UTF-8 byte 0x81 failed as invalid-json on Windows and passed under PYTHONUTF8=1. Same bytes, opposite verdicts. - tests/test_refusals_modeldrift_b.py read RESULTS.md the same way, so it failed on a cp1252 host before this branch touched anything. Pinned, with newline="" on the writes because those artifacts are sha256-pinned. On this host main is 2 failed / 236 passed; this branch is 0 failed / 262 passed. Every test was verified to fail against origin/main rather than merely to pass after it, except the bidi unit test, whose subject does not exist upstream. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: giulio d'erme <giulio@derme.dev>
858121c to
bb4dda3
Compare
|
Two updates you should probably know about, and one question. First, your encoding finding sent me looking for the same class everywhere Second, I stopped hand-hunting and measured. I disabled each of the verifier's Your PR #2 is rebased and verified locally but not landed: two of its new tests The question. The registry has 11 entries and all 11 are mine. That makes it one One thing changed since I last wrote, and it is the reason the ask is Entirely optional and on no timeline. If you would rather just rebase #2 when |
test_the_narrative_strip_is_not_quadratic asserted `time.monotonic()` under 4.0s. It ran in 0.05s, an 80x margin, but the margin was never the point: a verdict about this patch depended on how loaded the host was, which is the same class of defect the patch exists to remove. I flagged it in the PR body and offered to swap it. This is the swap, made before your review rather than after it, so nothing host-dependent is left in the branch to object to. Two deterministic tests replace it. EQUIVALENCE. _strip_tags is asserted equal to `re.sub(r"<[^>]+>", "", s)` over 20,022 strings from a `<>`-dense alphabet under a fixed seed, plus the edge cases that decide the semantics: "<>" is not a match, and a "<" with no later ">" stays literal. SPEC 3.5 pins the committed narrative against this transform, so a divergence would flip verdicts on honest bundles rather than merely change speed. The 200,000-string check quoted in the PR body was run ad hoc and never shipped; this ships a repeatable version of it. It has no upstream red state, since its subject does not exist on main. MECHANISM. The pathological bundle is unchanged (narrative.html of 120,000 "<"), but instead of timing the run it records every pattern handed to the engine and asserts that none contains `[^>]`. Both routes are recorded, re.sub(pattern, ...) and re.compile(pattern).sub(...), because recording only the first would let the quadratic form come back through the second unseen. A vacuity guard asserts the recorder saw something, since a recorder that observes nothing passes any such assertion. That is the operation-count bound I offered, in the only honest form available: the quadratic work happens inside the C engine, where no Python-level counter can observe it, so counting Python operations would assert nothing about the path that was slow. I would rather ship a bound that states what it proves than one that looks stronger and proves less. Verified red upstream, which the timing test could only claim: ported onto egnaro9/vac-protocol main, this test fails with `assert ['<[^>]+>'] == []`, and takes 23.6s there against 1.4s here. Suite on this branch: 267 passed, 16 skipped, 4 xfailed, 0 failed. ruff unchanged. Signed-off-by: giulio d'erme <giulio@derme.dev>
Not a verifier defect. A distribution one, and it lands on exactly the
person this project is aimed at: the stranger who clones and replays.
REPRODUCTION, on a clean machine with the Git for Windows installer defaults:
git clone https://github.com/egnaro9/vac-protocol.git
cd vac-protocol
python -m vac.verify fixtures/valid # exit 1
Twenty reasons, all of this shape:
FAIL sha256-mismatch: evidence/bundle.json: manifest 64931fe8..., file
a2433da2...
core.autocrlf is ON by default in that installer, the repository carries no
.gitattributes, so checkout rewrites every text artifact to CRLF and every
sha256 in the manifest stops matching its file. The bundle is honest, the
manifest is honest, the verifier is right to refuse what it was handed: the
bytes really did change between your commit and my disk. CI is ubuntu-only,
so nothing upstream can see this, and I hit it myself as the first thing that
happened when I re-cloned to work on #2.
It is the same class as the encoding finding in #2, one layer out. There the
same bytes produced opposite verdicts depending on the host codec; here the
same commit produces different BYTES depending on the host's git config.
Two parts, because a fresh clone and an existing one need different things.
`* -text` in .gitattributes turns the conversion off for every future clone.
Verified end to end: cloning this branch with core.autocrlf=true yields LF
artifacts and `python -m vac.verify fixtures/valid` exits 0, where the same
clone of main exits 1.
tests/test_checkout_integrity.py is the named reason for the clones that
already happened. A developer whose checkout is already converted currently
gets twenty hash mismatches and no cause; this gives one failure that says
what happened and the two commands that repair it. It also pins the
attribute, so dropping it later fails loudly rather than silently, on the
same principle as the rest of the suite: a gate that cannot name why it fired
cannot be audited.
The test file is new and touches nothing else, so this stands alone: it is
independent of #2 and #3 and can land in any order.
Verified: the three new tests pass here, the first fails against main (no
.gitattributes), ruff clean.
Signed-off-by: giulio d'erme <giulio@derme.dev>
Rebased onto main now that #1 is in. Single commit, no longer stacked.
Thank you for the revert note. You were right, and the reason is worse than a flaky test: my two deep-nesting tests built their fixture with
json.loads('{"a":' * 3000), which raises inside the decoder at a depth that varies by interpreter. Green on my 3.14, red on your 3.11 leg. So the tests for a host-dependent defect were themselves host-dependent, and the window they probed had an interpreter-dependent width.You offered two ways back. This takes the first one.
Both traversals over issuer data are now iterative
_todo_failureswalks the whole manifest and_summary_outrunswalksresults.summary. Both were recursive; both are now explicit stacks. There is no depth window left to be host-dependent about, so there is nothing host-dependent left to test.Order is preserved exactly: children are pushed in reverse so popping yields them in the original order, and a popped node's children are handled before its siblings, which is the depth-first order the recursive form gave. Your committed failure lists pin that, and they stay green.
The replacement tests build a 20000-deep structure in Python rather than through the decoder, so they exercise the traversal on every interpreter. The backstop's append-not-replace property is now tested by injecting a
RecursionErrorrather than by inducing one.The
RecursionErrorbackstop stays as defence in depth for traversals that are not iterative yet. It appends rather than replaces, because returning a fresh list would let an issuer delete every other reason, a smuggledunlisted-fileincluded, just by padding a structure with depth.Conflict resolutions worth naming
Crashkit severity: yours wins. You now refuse out-of-table labels instead of weighing them 0, which closes the hole properly rather than at the arithmetic. I kept your version and added one thing:
x in {...}and a set comprehension both raise on a list, so an unhashable severity escaped as aTypeErrorrather than as your named refusal. Hashability is tested first, and your message shape, ordering key andreprof the original value are unchanged, so both of your pinning tests stay green.verify_bundle: yours wins. The draft short-circuit stays ahead of the backstop, byte-identical in behaviour across four draft shapes including a real 21-reason draft.Crashes that escaped
verify_bundleEvery input is issuer-controlled JSON, so any assumption about a field's type was an assumption about the issuer's goodwill.
verdicts, fleetrows, or a raw jsonl lineevidenceorresults.checks, since5 or []is5dict.get()raises on an unhashable key rather than returning its default. The guard is hashability, not a string requirement: SPEC 3.2 and 3.3 type none ofsuite,memberoroperator_id, so a board keyed by integer ids is legal evidence, and two control tests hold that door open.t, previously coerced to""without a reasonfailsThe host-dependent verdict, which you called the most serious thing
read_textandwrite_textused the platform codec, so a manifest carrying a valid UTF-8 byte0x81failed asinvalid-jsonon Windows and passed underPYTHONUTF8=1. Same bytes, opposite answers.Three things that turned up while chasing it, none of which I expected:
vac/registry.pyre-reads the samevac.jsonwith the locale codec. Once the verifier stopped refusing those bundles, a bundle that now PASSES reachedregistry.py:156and crashed there, where before this branch it was refused with a named reason. That is a regression this branch would have introduced into the adjacent module, so all five sites there are pinned too. The structural guard now scansvac/*.pyrather thanverify.pyalone.tests/test_refusals_modeldrift_b.pyreadsRESULTS.mdthe same way, so it fails on a cp1252 host on main, before this branch touches anything. Pinned, withnewline=""on the writes because those artifacts are sha256-pinned. That is the only file of yours this PR edits, and it is six I/O calls, encoding only, no assertion changed.There are roughly 120 more bare text I/O sites across
tests/. They are latent rather than red, so I left them alone rather than swamp this diff. Happy to send that list separately.Also here
An unbounded scan on an issuer-controlled field.
re.sub(r"<[^>]+>", "", narr["html"])rescans to end-of-string from every start position when no>follows. A bundle that verifies completely clean cost 22.4s for 128k characters. The replacement is linear and reproduces the regex exactly, verified over 200,000 random strings from a<>-dense alphabet plus the edge cases, because SPEC 3.5 pins the committed narrative against this transform and redefining "tag-stripped" would flip verdicts on honest bundles. Same input now takes 0.004s.Issuer text reaching the terminal. The replay block is echoed after the verdict line and was printed raw, so ANSI escapes could paint a forged PASS over a failing run. Exit codes were always honest, so a CI gate keyed on exit status was never fooled. Bidi overrides and the Unicode separators are escaped too, since U+202E reverses the displayed order of a replay command a reader is invited to copy. The test builds those with
chr()rather than as literals, so the test file does not itself carry invisible reordering marks.Verification
On this host, imports pinned to each tree so neither borrows the other's
vac:python -m vac.verify fixtures/validexits 0, everytamper-*fixture still exits 1, the fixtures regenerate byte-identically, andruffoutput is unchanged.Every test was verified to fail against
origin/mainrather than merely to pass after it. The one exception is the_printableunit test, whose subject does not exist upstream, so it has no upstream red state; the CLI-level test covering the same defect does.For
refusal-coverage: the four refusals this branch adds are each pinned by their exact reason text, and I confirmed each one by mutating thef.appendaway and watching a test catch it. An assertion that only checks the failure list is non-empty survives that mutation through collateral damage, which is why they assert the full string.One thing to pre-empt:
test_the_narrative_strip_is_not_quadraticasserts wall-clock under 4.0s and runs in 0.05s, an 80x margin. Given the revert reason I would rather flag it than have you find it. If you would prefer no timing assertion at all, say so and I will swap it for an equivalence check plus an operation-count bound.Known residuals, deliberately out of scope
continuein the modeldrift coherence loop suppresses the remaining reasons for that one point. It cannot widen acceptance, since any appended failure aborts the check, but the report is less complete than the docstring promises.RecursionErrorraised inside_coherencestill loses that function's own partial reasons, because the result is only added on return. The phases before it survive.no verdicts[] arrayandno rows[] arraynow also fire when the array exists but holds a non-dict element, so the reason slightly misdescribes that input. I kept your wording verbatim rather than break the tests that pin it. Say the word and I will split it into two reasons.