Repository navigation
Admit floats as values; refuse fake exactness as float_not_exact - #45
Merged
Merged
Conversation
Customer float columns (FLOAT, REAL, DOUBLE) are legitimate types, so floats pass through, compare by IEEE equality and can key rows. What is refused is treating a float as exact: - _to_decimal(float) raises FloatNotExact (a TypeError subclass, reason float_not_exact) instead of returning Decimal(str(x)). - Repair paths (single table, joins, normal forms) report a float in a group, aggregate or predicate column as INVALIDATE with reason float_not_exact, separate from bool's invalid_evidence_type, so the refusal is visible in the certificate and the result is recomputed from origin. - SQLite cold rebuild: SUM over a column not declared decimal uses SMPL_STRICT_SUM, which refuses REAL values instead of summing their str() forms (0.1 + 0.2 summed to Decimal 0.3, a total the engine never computes). Declared decimal columns keep SMPL_DEC_SUM. - _values_equal: float vs float is IEEE equality; float vs exact is never equal. Float row keys keep identity via repr (1.0 -> 1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kAmYv4pLMyrspzKEWExZ
Owner
Author
|
Live Database Containers is red, but the failure is not from this PR.
Generated by Claude Code |
An external review of this PR found five paths where the event path refused
a float but a cold rebuild still produced an exact-looking answer. All five
reproduced; each is fixed here and pinned by a test that fails on injection.
- A float entering a verified-decimal column now revokes its exact-decimal
SUM for good (sticky per-column taint; DuckDB DECIMAL becomes DOUBLE).
Before, 0.1 then 0.2 rebuilt as Decimal("0.3").
- fetch_table_rows no longer turns floats into Decimal on the untyped path,
so join, shared-pool and per-shape pool rebuilds see the source float and
refuse it. A refused join rebuild empties and invalidates its cache and
marks uncacheable_reason, so no stale value stays servable; sibling pool
shapes still rebuild.
- SMPL_STRICT_SUM refuses TEXT/BLOB as invalid_evidence_type, matching the
exact fold, instead of summing numeric text as decimals while SQLite's own
SUM gives a binary float.
- process_or_rebuild returns UNSUPPORTED uncacheable_float_aggregate with
rebuilt=False and cache.uncacheable_reason="float_not_exact" when the cold
rebuild refuses floats, instead of raising out of the call.
- Float row keys are tagged FloatKey, so key identity agrees with the
type-strict _values_equal (1.0 no longer keys like 1, 0.1 not like
Decimal("0.1")). Float GROUP BY values on the SQL path are refused like
the exact fold's group keys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194kAmYv4pLMyrspzKEWExZ
A second external review found four paths where a cold answer could differ from the origin query. All reproduced; each is fixed and pinned by a test that fails on injection. - Provenance is the adapter's observed source kinds, not SQLite storage affinity. The string "2" written to an INTEGER column is stored as 2; the cold SUM now refuses it (invalid_evidence_type) as the exact fold does. Group columns with float, bool or mixed kinds are refused likewise. - Writes the adapter did not make are detected on SQLite through total_changes and revoke exact-decimal SUM eligibility (sticky), so a direct 0.2 beside an admitted 0.1 is refused, not summed to 0.3. DuckDB has no such counter; direct writes there are outside the guarantee, as the substrate docs now state. - Join keys must share one kind on both sides, as written and as stored. REAL 10.0 vs INTEGER 10 is refused as float_not_exact, TEXT '10' vs INTEGER 10 as mixed_join_key_types, on the SQL, manager and pool paths, instead of SQL joining what the type-strict cache would not. - SourceTypeRefused (a TypeError; FloatNotExact is one) carries a reason. process_or_rebuild returns UNSUPPORTED uncacheable_float_aggregate for float refusals and uncacheable_source_type for others, with the reason in cache.uncacheable_reason, so predicate refusals no longer escape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kAmYv4pLMyrspzKEWExZ
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kAmYv4pLMyrspzKEWExZ
…fuses Review fixes on top of 188213c (f33fe1a's source-provenance checks). Each one was reproduced on that head, and each new test fails there and passes here. - Registry: a refusal from the substrate rebuild escaped process_write_event and left the runtime state seeded. The next int INSERT repaired it and the store served 15 where origin's SUM is 15.5. A SourceTypeRefused is now an outcome: the runtime state is emptied, marked uncacheable, and the stored value stays invalidated. - Join manager: float group keys and float predicate values raised plain TypeError, and the old values stayed servable with a live aux state. _checked_group_atom, predicates.scalar, _comparable_extremum_val and JoinAuxState.from_tables now raise FloatNotExact. JoinCacheManager.rebuild and the pool's seed steps drop the old state on any failure. SourceTypeRefused and FloatNotExact move to predicates.py, and FloatNotExact is also a ValueError, so existing handlers of either type still catch it. - A cache marked uncacheable is never PRESERVEd or REPAIRed. A disjoint-column update used to return PRESERVE with empty values. - Extremum cold SQL path: MIN/MAX over REAL went through Decimal(str(x)) and gave Decimal('0.1'). It is now refused, as the fold and event paths already refuse it. Extremum and top-k refusals in process_or_rebuild get the UNSUPPORTED outcome. - SQLite: a float-tainted column's SUM goes through SMPL_FLOAT_SUM, a second guard behind the observed-kinds check for integral floats that INTEGER affinity stores as ints. - Pool events: a refusal in the on_invalidate rebuild no longer throws away the event's decisions. - FloatKey round-trips the store wire, and the uncacheable mark survives a checkpoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kAmYJQwpr9e2PFYJEZGR
dbccpage
marked this pull request as ready for review
November 11, 2026 08:09
dbccpage
pushed a commit
that referenced
this pull request
Nov 11, 2026
The third external review of the float PR (#45) found that two provenance bypasses survived its merge. Both reproduce on main; the tests fail there. - Writes from another SQLite connection are invisible to total_changes. The audit now also compares PRAGMA data_version, so a 0.2 committed by another connection beside an admitted 0.1 is refused, not summed to 0.3. - set_watermark() and create_and_seed_table() audit before they sync the trusted counters, so a public adapter call can no longer absorb an unobserved write. apply_write_event syncs its own write first, then writes its watermark through an internal, non-auditing path. - SortedMultisetExtremumCache carries uncacheable_reason, set when process_or_rebuild returns the UNSUPPORTED outcome for an extremum refusal and cleared by a clean rebuild, as AggregateCache does. The review's MIN/MAX and float GROUP BY findings were already fixed on main by #45's fourth commit; their tests here pass on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kAmYv4pLMyrspzKEWExZ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 Generated with Claude Code
Jeremy's rule: customers keep their types, so floats must be cacheable. A float may enter the cache, but it may never pretend to be exact. When a float cannot be handled exactly, it is refused by name and the refusal is visible as an event.
The invariant is that there is no path from a binary float to an exact aggregate. It holds on the event path and, since the second commit, on every cold-rebuild path too.
What changes
Event and repair path (first commit, 18f71e3)
_to_decimal(float)Decimal(str(x)), so 0.1 becameDecimal('0.1')FloatNotExact(subclass ofTypeError, reasonfloat_not_exact)invalid_evidence_type, the same reason as boolfloat_not_exact; bool keepsinvalid_evidence_typeSMPL_DEC_SUMsummedstr(x): 0.1 + 0.2 gaveDecimal('0.3'), while SQLite's own SUM is 0.30000000000000004SMPL_STRICT_SUMrefuses REAL asfloat_not_exact_values_equalstr()Cold-rebuild paths (second commit, 10f1088): fixes from the external review
SMPL_DEC_SUM, so the rebuild gaveDecimal('0.3')fetch_table_rowsturned floats into Decimal for join, shared-pool and per-shape pool rebuilds{'gold': Decimal('0.3')}SMPL_STRICT_SUMsummed numeric TEXT as decimals (0.3), while SQLite gives 0.30000000000000004invalid_evidence_type, as the exact fold refuses themprocess_or_rebuildraisedFloatNotExactout of the call after INVALIDATE(UNSUPPORTED "uncacheable_float_aggregate", cache, False)withcache.uncacheable_reason = "float_not_exact"and the cache emptied. The caller answers from the origin query. A later clean rebuild clears the mark0.1keyed likeDecimal('0.1'),1.0like1), contradicting_values_equalFloatKey, so they match only IEEE-equal floats. Float GROUP BY values on the SQL path are refused, like the exact fold's group keysBehaviour change.
A join over a REAL column that is equal to an int or Decimal key in another table no longer matches.[Edit: a float join or primary key is refused asfloat_not_exacton every join path, byJoinAuxState.from_tablessince the fourth commit.FloatKeynow only keysAggregateCachevalues built directly with float keys.]Source provenance (third commit, f33fe1a): fixes from the second review
The invariant is now stated precisely: a cached answer equals the origin query under the admitted semantics, or the query is refused.
"2"in an INTEGER column as2, so the cold SUM accepted what the exact fold refusesDecimal('3')invalid_evidence_type. Group columns holding float, bool or mixed kinds are refused the same way0.2beside an admitted0.1still summed toDecimal('0.3')total_changes, and exact-decimal eligibility is revoked for good. DuckDB has no such counter, so the guarantee there covers adapter-observed writes only. The substrate says so in its own docsgold = 5float_not_exactand TEXT '10' vs INTEGER 10 asmixed_join_key_types, on the SQL, manager and pool paths. Same-kind joins are tested equal to the origin SQL on every pathTypeErrorescapedprocess_or_rebuildSourceTypeRefused(TypeError)carries a reason, andFloatNotExactsubclasses it. The outcome is UNSUPPORTEDuncacheable_float_aggregatefor floats anduncacheable_source_typeotherwise, with the specific reason incache.uncacheable_reasonCost of the direct-write check. After any unobserved write, a decimal column is no longer certified, even if that write was exact. Integer columns are unaffected.
Review fixes (fourth commit, 299abcd)
Each finding was reproduced on
188213c, and each new test intests/test_float_cold_paths.pyfails there and passes here.RegisteredShapesRegistrywith a substrate: the rebuild's refusal escapedprocess_write_eventand left the runtime state seeded. The next int INSERT repaired it15where origin's SUM is15.5SourceTypeRefusedfrom the rebuild is an outcome. The runtime state is emptied and marked uncacheable, the stored value stays invalidated, and later events invalidate instead of repairingJoinCacheManager.rebuildwith a float predicate value or float group key raised plainTypeError. The old values stayed servable and the aux state stayed live{'gold': 99}stayedpredicates.scalar,_checked_group_atom,_comparable_extremum_valandJoinAuxState.from_tablesraiseFloatNotExactfor floats. The manager and the pool seed drop their old state on any rebuild failurecache.values == {}process_eventnever PRESERVEs or REPAIRs a cache marked uncacheable. It invalidates, so the next rebuild either refuses again or clears the markDecimal(str(x))Decimal('0.1')float_not_exact, as the fold and event paths already refuse it. Extremum and top-k refusals inprocess_or_rebuildget the UNSUPPORTED outcomeon_invalidaterebuild: a float refusal raised out ofprocess_eventand lost the event's decisions_rebuild_invalidatedkeeps the refusal as an outcome, since the shapes are already markedFloatKeykeys could not be stored (Unsupported cache wire type), and checkpoints droppeduncacheable_reasonfloat_keywire type, and the mark is saved and restored with the checkpoint2.0as2f33fe1aSMPL_FLOAT_SUMFloatNotExactandSourceTypeRefusedmove topredicates.py, the lowest layer, and are re-exported fromsmplcache.smplcache.FloatNotExactis also aValueError, because the join-key and extremum sites it replaced raisedValueError.Evidence (local, Python 3.13)
Fourth commit (299abcd)
pytest -q --ignore=tests/test_live_containers.py --ignore=tests/production_native.py, 2264 passed.black --check .andmypy smplcache demo scripts/check_sql_coverage.pyare clean, andscripts/check_sql_coverage.pyand the CLI smoke test pass.188213cand pass here.Full suite
.venv/bin/python -m pytest -q -p no:cacheprovider --ignore=tests/production_native.pyNew tests
tests/test_source_provenance.py: 11 tests. Each fix fails its tests when injected away (no aggregate provenance: 2 fail; no tripwire: 1; no mixed-key refusal: 1; float-only outcome: 2), and all 11 pass after reverting.tests/test_float_cold_paths.py: 16 tests, run on SQLite and DuckDB where relevant.tests/test_float_not_exact.py: 17 tests.Types and formatting:
mypy smplcachereports no issues in 40 files, andblack --checkis clean.Injections into the new fixes, each reverted afterwards (16/16 pass after reverting):
Not in this PR
debezium.pyandstore_adapter.pystill reads state JSON withDecimal(str(v)). Those values are written by smplcache itself, not taken from source floats.test_production_read_contention_during_publication[bounded]is timing-flaky onmain. It failed on49a3a5candd52e174, and passed on9e55b2fand3a838a2.https://claude.ai/code/session_0194kAmYv4pLMyrspzKEWExZ
Generated by Claude Code