DEV Community

Cover image for My test suite had 216 passing tests. It was lying to me.
Pavel Kazantsev πŸ’Ž
Pavel Kazantsev πŸ’Ž

Posted on Edited on Originally published at pkazantsev.com

My test suite had 216 passing tests. It was lying to me.

An audit found a nested JSON false PASS in a replay-versus-live trace comparator. The local fix is recursive and type-aware; CSV equality remains deliberately unverified without provenance.

[1] and [true] are different JSON values. My Python comparator said they were equal.

It returned:

{
  "state": "PASS",
  "deterministic_parity_verified": true,
  "findings": []
}
Enter fullscreen mode Exit fullscreen mode

That result came from a tool I built to compare a historical replay with a live-style decision pipeline. The point was to find the first recorded difference in features, model scores, decisions, configuration, or input evidence. A strict PASS was supposed to be the strongest possible result.

It was wrong.

The false PASS, reduced to five lines

The audit input was deliberately boring:

baseline = {"f": [1]}
candidate = {"f": [True]}
Enter fullscreen mode Exit fullscreen mode

Both values were valid JSON-like input. Both traces had matching metadata and input hashes. The public recorder wrote them, the parser read them, and the old CLI certified them.

The underlying Python behavior is legal:

[1] == [True]           # True
{"x": 1} == {"x": 1.0}  # True
Enter fullscreen mode Exit fullscreen mode

My comparator was asking the wrong question. Scalars went through type-aware logic, but nested lists and dictionaries eventually reached ordinary Python equality. For application code, that can be convenient. For a parity checker, it erases a recorded type difference.

Before / after: the same trace pair

I reran the preserved pre-fix source snapshot against this trace shape. It returned a verified strict PASS:

{
  "state": "PASS",
  "deterministic_parity_verified": true,
  "findings": []
}
Enter fullscreen mode Exit fullscreen mode

The public synthetic fixture now runs through the current CLI. This is the actual output:

$ hfm-parity diff --baseline examples/nested_values/baseline.jsonl \
    --candidate examples/nested_values/candidate.jsonl \
    --output parity-demo.html --json-output parity-demo.json
FAIL: mode=strict; not-verified; aligned=1; findings=1
Enter fullscreen mode Exit fullscreen mode

The JSON report identifies the leaf, its classification, and the recorded values:

{
  "state": "FAIL",
  "deterministic_parity_verified": false,
  "first_divergence": {
    "event_id": "evt-001",
    "stage": "features",
    "field": "payload[0]",
    "classification": "FEATURE_TYPE_MISMATCH",
    "baseline": 1,
    "candidate": true
  }
}
Enter fullscreen mode Exit fullscreen mode

The fixture, source code, and regression tests are public in HFM Parity Lab. It is synthetic by design: it proves a regression boundary, not a historical market measurement.

Before and after the nested type-mismatch fix

The pre-fix PASS was reproduced from the preserved source snapshot; the current FAIL is generated by the public synthetic fixture. The fixture demonstrates a regression boundary, not a historical market measurement.

Why 216 tests did not catch it

The candidate had 216 Python tests when the second audit supplied the nested counterexample. The scalar integer defect was fixed. Clean wheel and source installs worked. The CLI, MCP demo, Ruff, mypy, and frontend build passed.

The gap was structural: the tests covered scalar numeric semantics, but not the same semantics at every JSON leaf. More happy-path tests would not have made that contract explicit.

What the tool is intended to compare

Backtests and live pipelines often disagree for reasons that are annoyingly ordinary. A feature was defined differently. A model version changed. A timestamp uses a different clock. An event came from a different source. The final BUY/HOLD decision is usually too late in the chain to explain much.

I built a trace comparator called HFM Parity Lab around a narrower question:

Given two recorded executions, what is the first observable difference, and do we have enough evidence to say the executions are comparable?

Each JSONL trace records run metadata plus event-level fields: an event ID, stage, features, timestamps, input hash, model score, decision, and optional state hash. The engine aligns event.id + stage, then returns PASS, FAIL, NOT_COMPARABLE, or UNVERIFIABLE.

Strict mode is for two runs that claim to reproduce the same thing. Regression mode is for deliberate old-versus-new comparisons. Regression mode can report agreement, but it never certifies strict parity.

The first audit found other failures too

The first independent audit began with 87 passing tests and found nine problems. These three are the easiest to reproduce.

1. Large integers were converted to floats

The first comparison helper turned numbers into float values before comparison.

9007199254740993 != 9007199254740992
Enter fullscreen mode Exit fullscreen mode

Past 2**53, IEEE-754 doubles cannot represent every integer. The initial engine could call those values equal and return a verified strict PASS.

The fix compares integers exactly. An integer and a float are a type mismatch even when their values look equal; tolerance applies only when both values are floats. The repaired scalar tests cover values far beyond the floating-point range.

2. Matching event IDs could hide a different market

Two traces might both include evt-17, while one came from BTC and the other from ETH. The initial strict comparison did not treat symbol, venue, or data_source as part of the identity contract.

That makes event alignment look more meaningful than it is.

Strict mode now returns NOT_COMPARABLE for declared identity mismatches. It also needs a data source, input hashes on aligned events, an actual output to compare, and a model version when model scores exist. Missing evidence returns UNVERIFIABLE.

There is a practical difference between "I did not see a mismatch" and "the traces contain enough evidence to verify parity." A useful tool needs both answers.

3. The MCP output limit was per value, not per response

The first MCP formatter shortened nested values one at a time, then built the response. That did not constrain the total payload. The remediation report recorded a roughly 10 MB input yielding about 17.7 MB from one comparison call.

For a local agent tool, that is enough to flood a context window with repeated evidence.

The current implementation limits the whole response, preserves totals, labels truncation, and drops detail in a deterministic order. Its tests measure the actual structured plus text payload received by an MCP client as well as the formatting helper.

The audit also found recorder/parser disagreement, event reordering that could pass, missing provenance that looked like agreement, and a report viewer that could not open the external JSON it documented.

The P0 repair

The fix walks JSON-like values recursively and keeps the type explicit at every path: null, boolean, integer, float, string, array, object, or missing.

That makes the rules identical at the root and inside a container:

  • [1] versus [true] is a mismatch;
  • { "x": 1 } versus { "x": 1.0 } is a mismatch;
  • null and a missing key are distinct;
  • tolerance applies only to finite float-versus-float values.

In compact form, the contract is:

same(path) = same_json_type(a, b) ∧ compare_by_type(a, b)

integers: exact
floats:   finite(a, b) ∧ |a - b| ≀ Ξ΅
arrays:   same length ∧ same(index) for every index
objects:  same keys ∧ same(key) for every key
Enter fullscreen mode Exit fullscreen mode

Recursive type-aware parity contract

The comparison checks type before value at every nested path. Float tolerance applies only when both recorded values are floats.

This is a specification. Its purpose is to make the important bit reviewable: a convenient host-language equality operator never gets to silently redefine the verification contract.

The regression coverage takes those cases through the public recorder, JSONL reader, CLI, and a real stdio MCP process. The local suite is now 242 Python tests; Ruff, strict mypy, package builds, clean-wheel installation, frontend tests, and the frontend build also pass.

That is a precise claim about the known failure: the local release candidate no longer certifies those nested type differences as strict parity.

CSV onboarding and verdict strength

The same candidate adds a small CSV Quick Compare path. A developer maps identity columns, stages, features, types, and units from two CSV logs; the existing trace engine then aligns and compares them. It does not import raw exchange data or introduce a second diff engine.

The important limitation is deliberate: equal CSV rows normally cannot establish matching code, configuration, model, input snapshot, or clock domain. An equal comparison therefore stays UNVERIFIABLE unless that provenance is supplied. A mismatch remains useful evidence of a difference.

Verdict ladder distinguishing a mismatch from missing evidence and verified strict parity

A useful tool does not turn absent evidence into agreement. It says what the supplied records can and cannot support.

A test technique worth reusing

For any validator that can return a strong success result, write inputs that contradict the result by construction. For a JSON comparator, that means testing each rule at the root and inside objects and arrays:

  • True versus 1;
  • 1 versus 1.0;
  • a missing key versus null;
  • a value just inside and just outside float tolerance;
  • identical event IDs with different declared provenance.

This makes the guarantee concrete enough to attack. The repository is public; the reported 242 local tests and quality gates are useful evidence, not an independent correctness proof.

Try it

The repository includes only synthetic examples; it does not contain HFM market data or a trading system.

git clone https://github.com/icesurf666/hfm-parity-lab.git
cd hfm-parity-lab && python -m venv .venv && source .venv/bin/activate
pip install . && hfm-parity demo
Enter fullscreen mode Exit fullscreen mode

That writes a synthetic HTML and JSON report. The nested-value regression tests cover the false-PASS class described here; the README has the trace format and CSV workflow.

Top comments (16)

Collapse
 
alexshev profile image
Alex Shev •

The distinction between FAIL, NOT_COMPARABLE, and UNVERIFIABLE is as important as the recursive type check: it prevents a comparator from turning missing provenance into a reassuring result. A useful regression fixture would vary one provenance field at a time while keeping the payload equal, so the expected verdict boundary stays visible when the trace schema evolves.

Collapse
 
pavel_kkkkazantsev profile image
Pavel Kazantsev πŸ’Ž •

That fixture structure locks the boundary cleanly. One provenance field at a time keeps the expected verdict readable when the schema changes - and makes clear which field trips NOT_COMPARABLE versus which one triggers UNVERIFIABLE. The synthetic examples don't cover that matrix fully yet, that's next.

Collapse
 
alexshev profile image
Alex Shev •

Exactly. The useful next test is a table-driven matrix where each fixture breaks exactly one provenance invariant, plus a few combined failures to verify precedence. That gives you a stable contract for both schema evolution and error semantics.

Collapse
 
nathan_fixes profile image
Nathan •

The spec is the useful part. One hole I'd check before the fixture matrix lands: how same_json_type is implemented.

In Python isinstance(True, int) is True and True == 1 is True, so a gate written as isinstance(a, int) and isinstance(b, int) followed by exact == puts [1] and [true] back to equal, at every nested path. type(a) is type(b), or an explicit BOOL/INT tag, is the version that actually holds. Worth a fixture for 1 vs true at the root too, not only nested: the scalar path is usually written by a different hand than the recursive walk, and that's where a twin survives.

Two smaller things I'd pin in the same contract:

  • epsilon: say whether it's absolute or relative. An absolute 1e-9 on a float pair stored at 1e-8 scale is a different agreement than on one at 1e5. If it's absolute, add a pair that only fails under the relative reading.
  • non-finite floats: json.loads('[NaN, Infinity]') parses fine by default, and inf - inf <= eps is False, so inf vs inf becomes a mismatch. That may be the right answer, but it should be a named verdict rather than something that falls out of an arithmetic accident.

And the move that caught the original class is worth keeping as a test: assert the negative. A unit fixture planted in a nested container whose __eq__ raises proves the walk never reaches host equality, which no set of positive fixtures can prove. Same reason your NOT_COMPARABLE/UNVERIFIABLE split is stronger than a bigger list of PASS cases.

Collapse
 
pavel_kkkkazantsev profile image
Pavel Kazantsev πŸ’Ž •

That’s a good catch. I checked the implementation rather than assuming the nested fix covered the scalar path.
bool is tagged before int, so True vs 1 is a type mismatch at the root as well as inside a list or object. There was already a root-level regression test; I also added a poisoned-container test whose eq raises, so a future refactor cannot quietly fall back to host container equality.
Float agreement is not absolute-only. It uses:
abs(a - b) <= max(abs_tol, rel_tol * max(abs(a), abs(b)))
I added a fixture at a large magnitude that fails with absolute tolerance alone and passes only when relative tolerance is supplied.
Non-finite values are outside the public trace contract: both the JSONL reader and TraceRecorder reject NaN and Infinity before comparison. There is separate documented in-memory behavior for internal tests, but it is not reachable through a valid recorded trace. I agree that boundary needs to remain explicit rather than becoming an arithmetic side effect.
And yes β€” the negative assertion is the important part. A passing matrix shows examples that work; the poisoned eq case proves the recursive walk does not delegate equality to Python containers.
The poisoned eq case is going into the regression suite as a permanent fixture.

Collapse
 
nathan_fixes profile image
Nathan •

That closes it. One more of the same class, cheap to catch now: duplicate keys inside one object. json.loads silently keeps the last one, so {"f": 1, "f": 1.5} and {"f": 1.5} arrive at the comparator identical, and the earlier recorded value never existed for either side.

That's the bool hole's cousin - a difference erased before the walk instead of during it, this time by the parser. An object_pairs_hook that raises (or records) on a repeated key inside a single object sits next to your NaN/Infinity rejection rather than in the typed comparison. Fixture shape is the same as the poisoned container: identical payload, one duplicate key, expected verdict NOT_COMPARABLE, not PASS.

Thread Thread
 
pavel_kkkkazantsev profile image
Pavel Kazantsev πŸ’Ž •

You're right. I initially misunderstood the distinction you were making. I treated parser rejection as sufficient, but your point was that diff still needs to leave an auditable no-comparison result instead of disappearing into an input error.

I changed that. A duplicate key at any nesting depth is now preserved as TRACE_INVALID in a NOT_COMPARABLE diff report, with zero aligned events and the parser reason attached. The nested features.f.value fixture is in the test suite too.

validate still rejects one malformed trace as invalid input; diff now records why two traces cannot be compared.

Thanks. "Erased before the walk" was exactly the right way to frame it.

Thread Thread
 
nathan_fixes profile image
Nathan •

Good. The part I'd guard now is the caller, not the parser.

NOT_COMPARABLE with zero aligned events is easy to treat as "nothing to report" by accident. If any summary or exit code only branches on equal/different, a run with one invalid pair reads as clean. So: exhaustive switch over the diff status with no default that swallows the new state, and not_comparable printed next to the counts in the run summary. A "0 differences" headline that hides one TRACE_INVALID is the same bug one layer up.

The invariant worth asserting: for each side, aligned + only_left + only_right + invalid_pairs = total input records. Any record that vanishes is the bug you just fixed, and that assertion catches it wherever it tries to disappear next.

If you want this class swept before you cut a release instead of one comment at a time: I do a paid pass, $25 by card, source-level diagnosis + patch + the test that fails first. Say yes and I'll send a link. If not, the fix is the good part and I'll keep reading.

Collapse
 
pm25coder profile image
pm25coder •

@nathan_fixes I read the caller half in the source, and part of the worry is already closed: EXIT_CODES at interfaces/cli.py:21 maps NOT_COMPARABLE and UNVERIFIABLE to 2, PASS to 0, FAIL to 1, and tests/test_cli.py:38 asserts that 2. A caller that only asks "zero or not zero" already stops on both new states; what swallows them is a "zero means clean" habit, and exit 2 sits outside it. The two states share that code, so which one happened has to come from the printed word or the JSON.

The invariant is where I would push, because it cannot be evaluated yet: four terms, one computed. domain/models.py:114 gives ComparisonReport six fields and the only count is aligned_events. A one-sided key becomes a finding at application/compare.py:38 instead of a counter, a duplicate becomes a finding per key at compare.py:35-37, and cli.py:81 prints aligned plus the finding count. A log reader holds one of your four terms and nothing to sum it against.

The testable form is per side, since the traces can hold different record counts and a duplicate is neither aligned nor one-sided: aligned + only_left + ambiguous_left == len(baseline.observations), and the matching equation on the candidate. Three integers onto the report, computed in the rank loop at compare.py:33, put the assertion at the boundary that builds the report, where an upstream disappearance has to pass.

I read the source, not the suite: a reading, not a reproduced failure.

Collapse
 
nathan_fixes profile image
Nathan •

Right about the caller. cli.py:21 already separates the states from the zero-means-clean path, and my worry was about habit one layer up, not the mapping. That part of the point is dead.

On the equation: the shape is right, per side, since the traces can differ in length and a duplicate is neither aligned nor one-sided. But as written it cannot fail. A duplicate becomes a finding per key at compare.py:35-37, and the record under that key still goes through the rank loop, so it is in ambiguous and in aligned at once, and the sum runs over len(observations). The repair that suggests itself, ambiguous = total - aligned - only_left, makes the assertion a restatement of the definition. It passes on any input.

What makes it bite is one status per record, assigned once at the end of the rank loop. Then a second write is itself the detectable bug, and TRACE_INVALID stays a report-level flag instead of a bucket, because a malformed trace has no records to classify.

One thing I can't see from your read: is a one-sided record tagged with its side, or are only_left and only_right two counters? If the second, the baseline equation is missing a term and the candidate equation is missing a different one.

Collapse
 
pm25coder profile image
pm25coder •

@nathan_fixes Neither, and that is what breaks the equation. There are no per-side counters anywhere in the report: compare.py:39-40 emits one finding per one-sided key, carrying the side as two booleans in the value fields, baseline=a is not None and candidate=b is not None. So both equations can be built from the same findings, each filtering on its own boolean. But that filter reads a field typed Any, which other findings use to carry real values β€” align.py:27-28 puts sequence numbers there, align.py:54 puts event ids. A count keyed on it is a type check, not a declared side.

Your mechanism is not what this head does, though: ambiguous is the if branch at compare.py:35, so an ambiguous key never reaches the aligned increment. I ran it instead of arguing, four constructed pairs. Clean, one key only in the baseline: aligned 2, one-sided 1, records 3, equation holds. One duplicate inside the baseline: aligned 1, one-sided 0, records 3, residue 2 β€” two records, one key. That is the real reason the assertion cannot be evaluated: the terms are counted per record and the thing they are subtracted from is a set counted per key.

It gets worse than that, and this is the part I got wrong. compare.py:28 unions the two sides' ambiguous sets. A duplicate in the baseline therefore also removes that key from aligned on the candidate: in my duplicate case the candidate held that key once, nothing was one-sided there, and its residue still came to 1.

So I agree the assertion as written cannot be evaluated, and I agree that ambiguous = total - aligned - one-sided is a restatement. For your one-status-per-record fix, if the union stays, the second write is the bug you named and the shared cause has to be recorded on each side's record rather than inferred from set membership β€” because the count the equation needs is per side, and the set it reads is not.

Thread Thread
 
nathan_fixes profile image
Nathan •

Then the mechanism point is yours and mine was wrong. ambiguous is the if branch at compare.py:35, so a duplicated key never reaches aligned += 1. I argued from the shape of the equation; you ran it. I have the repo cloned now and I am reading the same lines you did.

The union is worse than what I claimed and I did not see it: line 28 unions both sides' ambiguous sets before the loop, and align.py order_findings subtracts the same union from shared. So one side's duplicate edits the other side's verdict, twice.

It also leaves a hole in my own proposal. The candidate record for that key now has no status anywhere: not aligned, not one-sided, not invalid, because the invalid record lived in the baseline. That is the term my four were missing, and I will call it counterpart_invalid. One-status-per-record quietly assumed every record had a partner to be judged against.

The last thing, and it is why a per-record equation cannot be built from the report as it stands. The one-sided findings at 39-40 do carry the side, as two booleans in Any fields, which you are right to call a type check. The duplicate findings do not: align.py:19-22 emits one per dropped record with event_id, stage and severity, no side and no record index. index keeps the first record for that key and drops the rest, so the only trace that a specific record vanished is a finding that says nothing about which side it vanished from.

Order I would want: side declared on every finding, one status per record per side including counterpart_invalid, and the union reduced to a lookup that order_findings uses and nothing else counts. Then per side the terms are records = aligned + only_side + counterpart_invalid + invalid, and the assertion can finally fail, because a record leaving the loop with no status written is itself the finding.

Collapse
 
ascendra_ventures_92c21f2 profile image
Ascendra Ventures •

[1] == [True] passing is a good reminder that 216 green tests only describe the cases somebody thought of. Python treating True as 1 is documented behaviour, which is why it slips through: nothing is broken, the comparison is simply answering a different question.

Adding NOT_COMPARABLE and UNVERIFIABLE next to PASS and FAIL is the part I like most. A validator that can say it cannot tell is more useful than one that always produces a verdict.

Nathan's follow up deserves attention, though. Tagging bool before int and catching duplicate keys fixes the validator, but if the caller treats NOT_COMPARABLE as clean, the false pass comes back one layer up. The contract has to cover what the caller does with each outcome.

Kartik's example of agent eval tool arguments like {"limit": True} shows this is not only a test tooling problem. Agents produce exactly these values, so the same checks belong wherever their output is validated.

Collapse
 
kartik-nvjk profile image
Kartik N V J K •

Python's [1] == [True] sent me straight to how my agent evals compare expected tool-call arguments. A plain dict compare passes {"limit": True} against an expected {"limit": 1}, and I don't have a case for that yet. I'm stealing your root-and-nested pairs for that suite.

Collapse
 
pavel_kkkkazantsev profile image
Pavel Kazantsev πŸ’Ž • • Edited

The agent eval case is worse in practice - tool arguments come back from an LLM, so True instead of 1 is exactly the kind of silent coercion you'd get. Root-and-nested pairs catch it; the scalar path alone won't.

One thing worth adding to your suite: {"limit": 1} vs {"limit": 1.0}. Same trap, different direction.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

216 passing tests and the validator still lied. brutal.