Skip to content

Admit floats as values; refuse fake exactness as float_not_exact - #45

Merged
dbccpage merged 5 commits into
mainfrom
claude/project-thread-lc05y5
Nov 11, 2026
Merged

dbccpage merged 5 commits into
mainfrom
claude/project-thread-lc05y5

Conversation

@dbccpage

@dbccpage dbccpage commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner

🤖 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)

Site Before After
_to_decimal(float) Converted with Decimal(str(x)), so 0.1 became Decimal('0.1') Raises FloatNotExact (subclass of TypeError, reason float_not_exact)
Repair checks (single table, joins, normal forms) A float in a group, aggregate or predicate column gave INVALIDATE invalid_evidence_type, the same reason as bool Gives INVALIDATE float_not_exact; bool keeps invalid_evidence_type
SQLite cold SUM over a column not declared decimal SMPL_DEC_SUM summed str(x): 0.1 + 0.2 gave Decimal('0.3'), while SQLite's own SUM is 0.30000000000000004 SMPL_STRICT_SUM refuses REAL as float_not_exact
_values_equal Two floats were compared through str() IEEE equality between two floats. A float never equals an exact number

Cold-rebuild paths (second commit, 10f1088): fixes from the external review

# Review finding Reproduced Fix
P0 A float entering a verified-decimal column kept SMPL_DEC_SUM, so the rebuild gave Decimal('0.3') Yes Sticky per-column float taint revokes exact-decimal eligibility for good. Later decimals cannot re-admit it. On DuckDB, DECIMAL becomes DOUBLE
P0 fetch_table_rows turned floats into Decimal for join, shared-pool and per-shape pool rebuilds Yes, all three gave {'gold': Decimal('0.3')} Floats stay floats on every fetch. A refused join rebuild empties and invalidates its cache, so no stale value survives. Sibling pool shapes still rebuild, and the call raises once, naming the refused shapes
P1 SMPL_STRICT_SUM summed numeric TEXT as decimals (0.3), while SQLite gives 0.30000000000000004 Yes TEXT and BLOB are refused as invalid_evidence_type, as the exact fold refuses them
P1 process_or_rebuild raised FloatNotExact out of the call after INVALIDATE Yes Returns (UNSUPPORTED "uncacheable_float_aggregate", cache, False) with cache.uncacheable_reason = "float_not_exact" and the cache emptied. The caller answers from the origin query. A later clean rebuild clears the mark
P2 Float keys normalized like exact numbers (0.1 keyed like Decimal('0.1'), 1.0 like 1), contradicting _values_equal Yes Float row keys are a tagged FloatKey, so they match only IEEE-equal floats. Float GROUP BY values on the SQL path are refused, like the exact fold's group keys

Behaviour 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 as float_not_exact on every join path, by JoinAuxState.from_tables since the fourth commit. FloatKey now only keys AggregateCache values 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.

# Review finding Reproduced Fix
P1 SQLite affinity stores "2" in an INTEGER column as 2, so the cold SUM accepted what the exact fold refuses Yes, Decimal('3') Provenance is the adapter's observed source kinds, not storage affinity. A SUM/AVG column whose observed kinds aren't all exact numbers is refused as invalid_evidence_type. Group columns holding float, bool or mixed kinds are refused the same way
P1 A direct SQL 0.2 beside an admitted 0.1 still summed to Decimal('0.3') Yes On SQLite, writes the adapter did not make are detected through 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 docs
P1 The SQL join matched REAL 10.0 to INTEGER 10, while the Python path refused it Yes, SQL gave gold = 5 Join keys must share one kind on both sides, both as written and as stored. Float keys are refused as float_not_exact and TEXT '10' vs INTEGER 10 as mixed_join_key_types, on the SQL, manager and pool paths. Same-kind joins are tested equal to the origin SQL on every path
P2 Float-predicate TypeError escaped process_or_rebuild Yes SourceTypeRefused(TypeError) carries a reason, and FloatNotExact subclasses it. The outcome is UNSUPPORTED uncacheable_float_aggregate for floats and uncacheable_source_type otherwise, with the specific reason in cache.uncacheable_reason

Cost 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 in tests/test_float_cold_paths.py fails there and passes here.

# Finding Reproduced Fix
P0 RegisteredShapesRegistry with a substrate: the rebuild's refusal escaped process_write_event and left the runtime state seeded. The next int INSERT repaired it Yes. The store served 15 where origin's SUM is 15.5 A SourceTypeRefused from the rebuild is an outcome. The runtime state is emptied and marked uncacheable, the stored value stays invalidated, and later events invalidate instead of repairing
P1 JoinCacheManager.rebuild with a float predicate value or float group key raised plain TypeError. The old values stayed servable and the aux state stayed live Yes, the stale {'gold': 99} stayed predicates.scalar, _checked_group_atom, _comparable_extremum_val and JoinAuxState.from_tables raise FloatNotExact for floats. The manager and the pool seed drop their old state on any rebuild failure
P1 After a refusal, a disjoint-column UPDATE returned PRESERVE with cache.values == {} Yes process_event never PRESERVEs or REPAIRs a cache marked uncacheable. It invalidates, so the next rebuild either refuses again or clears the mark
P1 The extremum cold SQL path read MIN/MAX over REAL through Decimal(str(x)) Yes, Decimal('0.1') Refused as float_not_exact, as the fold and event paths already refuse it. Extremum and top-k refusals in process_or_rebuild get the UNSUPPORTED outcome
P2 Pool on_invalidate rebuild: a float refusal raised out of process_event and lost the event's decisions Yes _rebuild_invalidated keeps the refusal as an outcome, since the shapes are already marked
P2 FloatKey keys could not be stored (Unsupported cache wire type), and checkpoints dropped uncacheable_reason Yes float_key wire type, and the mark is saved and restored with the checkpoint
P3 SQLite INTEGER affinity stores 2.0 as 2 Already refused by f33fe1a Second guard: a float-tainted column sums through SMPL_FLOAT_SUM

FloatNotExact and SourceTypeRefused move to predicates.py, the lowest layer, and are re-exported from smplcache.smplcache. FloatNotExact is also a ValueError, because the join-key and extremum sites it replaced raised ValueError.

Evidence (local, Python 3.13)

  • Fourth commit (299abcd)

    • Full suite: pytest -q --ignore=tests/test_live_containers.py --ignore=tests/production_native.py, 2264 passed.
    • black --check . and mypy smplcache demo scripts/check_sql_coverage.py are clean, and scripts/check_sql_coverage.py and the CLI smoke test pass.
    • 13 new or tightened tests: all fail on 188213c and pass here.
  • Full suite

    • Command: .venv/bin/python -m pytest -q -p no:cacheprovider --ignore=tests/production_native.py
    • Result: exit 0. The skips are the live-container tests.
  • New 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 smplcache reports no issues in 40 files, and black --check is clean.

  • Injections into the new fixes, each reverted afterwards (16/16 pass after reverting):

    Injection Result
    No revocation 1 failed, the SQLite transition test. On DuckDB the DOUBLE change still refuses, as a second guard
    Float→Decimal restored in the fetch 4 failed: the fetch, manager and both pool tests
    Text admitted to the strict sum 1 failed
    Uncacheable outcome removed 3 failed

Not in this PR

  • Saved-state reload in debezium.py and store_adapter.py still reads state JSON with Decimal(str(v)). Those values are written by smplcache itself, not taken from source floats.
  • The native CI job. test_production_read_contention_during_publication[bounded] is timing-flaky on main. It failed on 49a3a5c and d52e174, and passed on 9e55b2f and 3a838a2.
    • Cause: the bounded worker drains about 13 generations/s against the writer's 20 commits/s, so the backlog takes 29 to 41 s against a 30 s limit.
    • The fix awaits Jeremy's choice and will come in its own PR.

https://claude.ai/code/session_0194kAmYv4pLMyrspzKEWExZ


Generated by Claude Code

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

dbccpage commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner Author

Live Database Containers is red, but the failure is not from this PR.

  • The failure: 1 failed, 53 passed. The failing test is tests/production_native.py::test_production_read_contention_during_publication[bounded], with "native source did not catch up within 30 seconds".
  • Same on main: that test fails the same way on main, before this diff [Edit: the test is timing-flaky on main, not deterministically red. It failed on 49a3a5c and d52e174, and passed on 9e55b2f and on the current 3a838a2 (Reconnect dead worker sessions and retry strict reads that raced the worker #43).] I reproduced it locally against Postgres 16 with wal2json.
  • Cause: the bounded worker publishes one signed generation per WAL transaction. That is about 13 generations/s against a writer doing about 20 commits/s. The backlog of about 440 drains in 29 to 41 s, against a 30 s catch_up limit, so a run passes or fails depending on where in that range it lands.
  • Fix: none yet. The fix changes the publication protocol, so it waits on the maintainer's choice and will come in its own PR.
  • Not re-run: re-running would only spend Actions minutes on a known flake.

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
dbccpage marked this pull request as ready for review November 11, 2026 08:09
@dbccpage
dbccpage merged commit 0ae2ea7 into main Nov 11, 2026
5 checks passed
@dbccpage
dbccpage deleted the claude/project-thread-lc05y5 branch 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
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.

2 participants