Repository navigation
fix(stats): reconcile novelty asserts against the base index for user-facing counts - #1699
Conversation
…-facing counts `assemble_fast_stats` folded novelty onto the indexed counts as a blind +1/-1 delta log. Novelty's own set-semantics dedup (`NoveltyFactState`) is scoped to the novelty window, so a fact that has been reindexed into the base and dropped from the window is kept as a fresh novelty flake when it is asserted again -- and charged a second +1 on top of the base count it duplicates. The mirror case is the same: novelty accepts every retraction, so deleting a fact that was never there charged -1. Adds `NoveltyDeltaResolver`, which walks the novelty POST stream and resolves the FIRST flake of each fact identity against the persisted base index (one bounded SPOT scan per graph/subject/predicate, cached for the assembly, read against an empty overlay at the indexed t). Every later flake of that identity is an exact state transition already, since novelty's kept flakes strictly alternate assert/retract. Wired on for the surfaces that render counts to people -- `ledger_info` and the Cypher catalog shims, including `apoc.meta.data`'s own inline delta log. The planner's `stats_cache` and `policy_builder` keep the estimate lane, documented in place: every COUNT-serving fast path already declines under non-empty novelty, and the planner view rebuilds on every overlay epoch.
…base Review caught that the first-flake-only probe rested on an invariant the novelty crate does not provide. `Novelty::apply_commit`'s dedup gate is `if flake.op && self.fact_state.is_asserted(..)`, which short-circuits, so it suppresses redundant ASSERTS only and never examines a retraction. And because `cmp_post` sorts `op` ascending at equal `t` while `fact_state` resolves a same-`t` pair retract-wins, the losing assert reaches the fold last. Kept flakes therefore do not alternate: runs like (retract, retract) and (retract, assert, assert) are both reachable, the second deliberately so -- `bulk_apply_commits` replays raw persisted flakes on cold load, which is why the crate pins that state as legal. `NoveltyDeltaResolver` now folds PRESENCE rather than ops: per identity it carries the op that has won lifecycle resolution (the same rule `fact_state::record` applies) and charges each flake the change in presence it causes. The deltas telescope, so their sum is `final_present - base_present` however many flakes the window holds and in whatever order they arrive. That is stronger than guarding the two known non-transitions -- it also gets two consecutive same-`t` pairs right, which a previous-op comparison does not. Also from the review: - stamp the calling surface (`ledger-info-full` / `ledger-info-fast` / `apoc-meta-data` / `merged-stats`) instead of one shared label, so a must-fire routing assertion names the lane it means; all four sites are now pinned individually. - reset `duplicates` in `restart_walk`: the two-pass full assembly (the DEFAULT `fluree info` path) was stamping 2x, which made the test's duplicate floor easier to satisfy -- the wrong direction for an anti-vacuity guard. - skip the base probe for commit-metadata flakes: their subject is the commit's own CID digest, so no base fact can share the identity, and `include_in_runtime_stats` discards them anyway. 7-10 guaranteed cache misses per commit in the window, buying nothing. - binary-search the per-`(s, p)` cache instead of scanning it, and sort it explicitly rather than trusting provider order. - measure the cap's cost instead of asserting it (#1391 asked for the measurement): ~4.3us per novelty flake, ~210ms at the 50k ceiling, reproducible via an `#[ignore]`d test. - correct three comments that were wrong or incomplete: `stats_cache` (several COUNT lanes deliberately do not gate on `fast_path_store`; `property_ref_only` is not merely an estimate), `policy_builder` (never reads a count is not the same as count drift cannot matter), and `class_property.rs` (unreachable, not audited-safe). - cover the under-count half end to end: the fixture now includes a ground DELETE that matches nothing, which does reach novelty as a retraction flake through the public API.
…ts two answers The comment said the `property_ref_only` / `filter_fold` hazard was "tracked separately" while nothing tracked it. It is #1721 now, so point at it — a reference without a referent is the failure this whole program exists to clean up. Also reorders the block so the two questions a reader asks first are answered before the detail rather than after it: the planner lane is not reconciled and why (quadratic against a view rebuilt every commit), and no COUNT answer rides on this view (the lanes that run under novelty read through an overlay-merging cursor, not these stats).
bplatz
left a comment
There was a problem hiding this comment.
Approving — the mechanism is sound and the adversarial work is the right kind of thorough. I verified the parts that are easy to get wrong: the presence-fold telescopes correctly, the refusal to assume assert/retract alternation is genuinely necessary (confirmed apply_commit's one-sided gate and POST's retract-first ordering at equal t), and base_contains sorting on (o, dt) with Ord while grouping m with == is the correct pairing. The #1721 pointer added in d466c60 checks out — the issue exists and is honestly scoped.
Four inline items. The first (class attribution on newly-typed subjects) is the one I'd want settled before merge — it's a new undercount on the same surfaces this PR exists to fix, and the fixture can't see it. The others are a doc fix, a pre-existing defect worth naming, and a follow-up perf plan.
One more for What's left, pre-existing and unchanged here: when a subject's rdf:type lands in the same novelty window as its property flakes, assemble_full_stats attributes the property twice — once via pass 1's intra-pass graph_subject_classes side table, once via pass 2's lookup_subject_classes + subject_props. One fact reports [(0, 2)] under both merge modes (same fixture as the inline comment below, genuinely-new variant). Since it drifts the reconciled surfaces themselves, it belongs in the residuals list — otherwise the body reads as if those four surfaces are now exact.
| if !include_in_runtime_stats(flake, to_t) { | ||
| continue; | ||
| } | ||
| if delta == 0 { |
There was a problem hiding this comment.
This skip is right for the flat counts but wrong for class attribution, and it introduces a new undercount. The probe answers "is this fact in the base index?" — pass 2 needs "did the base rollup count this fact under this class?" Those diverge exactly when the subject's class membership is new in the window.
Built on this PR's own StubBase/StubLookup harness: base holds alice ex:name "n" with no type; novelty asserts rdf:type Person and restates the name. Truth is Person.name = 1:
deltas = [type: +1, name: 0]
Estimate Person = (count 1, [("name", [(0, 2)])])
Reconciled Person = (count 1, [("name", [])]) ← truth is 1
The fact is base-present so it's charged 0 — but the base rollup never attributed it to Person either (alice had no class at index time), so the skip suppresses the only contribution that would have. In apoc.meta.data, source 1 skips count == 0 datatypes, so the row disappears entirely — the exact mirror of #1391, on the surfaces this PR fixes.
Trigger is ordinary: import untyped, then upsert the same documents with @type — any whole-document re-upsert that adds a type. The differential test can't see it: ex:w1/ex:w2 have their types already in base and ex:w3 is entirely new, so nothing in the fixture is "newly typed subject with already-indexed properties".
The skip needs to be conditional on the subject's class set being unchanged from base (or pass 2 needs to treat base-present facts on newly-typed subjects as still attributable). If that's too invasive for this PR, it should at least be named in What's left as a new residual — right now the body reads as if the four surfaces are exact.
| /// Identity comparison uses `Ord`, matching both the index comparators and | ||
| /// `NoveltyFactState`'s key ordering — `FlakeValue`'s `Eq` is looser across | ||
| /// numeric representations, and equating `Long(3)` with `Double(3.0)` here | ||
| /// would suppress a legitimate assertion. |
There was a problem hiding this comment.
The rationale here is backwards, though the code is right. Both Ord and Eq route cross-variant numerics through numeric_cmp — they're the same relation there. What actually keeps Long(3) and Double(3.0) apart in this comparison is the dt tiebreak, not the choice of Ord over Eq.
And the one place the two relations do differ, Ord is the looser one: Double(NaN) compares Equal via to_bits while == is false — so under Ord a NaN re-assert is charged as a duplicate, which is the (harmless, unreachable-in-practice) opposite of what this comment warns about. Worth rewording so the next reader doesn't "fix" it in the wrong direction; same Ord/Eq family as #1711.
| /// | ||
| /// Above the cap, declining keeps the surface responsive and the counts carry | ||
| /// the same drift this fix removes — the stamp records which happened. | ||
| const MAX_RECONCILED_NOVELTY_FLAKES: usize = 50_000; |
There was a problem hiding this comment.
Follow-up perf plan worth filing — the 28x is confined to metadata calls, but the callers that hurt (db.* / apoc.meta.data in a schema-introspection loop) repeat between commits, and every call re-probes from a cold cache.
1. Epoch-keyed shared probe cache. The exact precedent exists: SchemaHierarchyCache (fluree-db-core/src/schema_hierarchy.rs:612) — single-slot RwLock<Option<CacheEntry>> keyed (schema_t, schema_epoch), Arc-shared on LedgerState (fluree-db-ledger/src/lib.rs:146), no explicit invalidation, carried across commits. Cache the probe layer, not the assembled output: HashMap<BaseFactKey, Vec<BaseFact>> keyed (snapshot.t, novelty.epoch). Probe results are independent of to_t (reads are as-of indexed_t against an empty overlay), which sidesteps merged_stats' i64::MAX vs ledger_info's ledger.t(); and they're policy-independent, so one cache safely serves meta_data_rows under any enforcer, which an output cache could not. Repeat calls drop to estimate cost (~1.5–7.6ms by this doc's own numbers); the scans stamp is already the test hook (scans == 0 second call, > 0 after a commit).
2. Predicate-presence skip, ~10 lines, zero I/O: predicates absent from indexed.properties can't have base facts — count includes history and aggregate_property_entries_from_graphs does no pruning, so absence means never indexed. (An ns-split reconstruction false-miss degrades to probing — safe direction.) Note the win is smaller than it looks: a novelty-only-predicate probe already short-circuits inside the provider (sid_to_p_id miss → overlay_only_flakes over the empty overlay, no leaflet I/O), so this shaves dict resolution + dispatch + a Vec alloc, not disk. That fact is also worth adding to this doc's cost argument.
Separate consideration: the first call after a commit still runs up to ~210ms of synchronous RangeProvider::range on a tokio worker inside async routes — the cache doesn't help that one; spawn_blocking around the sync assembly would.
All follow-up material, not this PR — happy to file it.
… window The base-presence probe answers "is this fact in the base index?", which is the right question for the flat counts and the wrong one for class attribution. The two diverge exactly when a subject's class membership is new in the novelty window: the fact is base-present so reconciliation charges it zero, but the base rollup filed it under the subject's classes AS OF THE INDEX, and the gained class was not among them. The restatement is that row's only possible contributor, so charging it zero deletes the row -- an undercount with the same shape as the over-count this change exists to remove. `apoc.meta.data` drops zero-count datatypes outright, so the row does not read low, it disappears. The trigger is ordinary: import documents untyped, then re-upsert them with `@type`. `delta > 0` on an `rdf:type` flake is the signal, and all three folds now use it. A restatement is charged `+1` under the classes its subject gained here and nothing under the ones it already had -- so the class the base rollup already counted it under is not charged twice. `assemble_fast_stats_inner` takes a `RestatedAttribution` saying whether a class-attributing second pass follows. Exactly one fold may make up the difference: the fast arm does it from its intra-pass `rdf:type` side table, and the two-pass assembly defers to the second pass, which resolves classes through `lookup_subject_classes` and so does not depend on where `rdf:type` lands in POST order. `meta_data_rows` reaches the same answer from its own two passes: pass A now drives the resolver to learn which memberships are new, and pass B charges a restatement under those. Estimate-lane behavior is unchanged -- no flake resolves to zero there, and the gained-class table is not even built. Two comment corrections while in here. `base_contains` claimed `Ord` was the stricter relation and `Eq` would equate `Long(3)` with `Double(3.0)`; both route cross-representation numerics through `numeric_cmp`, so they are the same relation there and it is the `dt` tiebreak that separates the two values. And the second pass's double attribution of a subject typed inside the window is now named where it happens rather than only in the issue.
|
Thanks @bplatz — the class-attribution catch was the right thing to block on, and chasing it turned up more than the one site. It lives in three folds, not one, and the third is the surface you named. Reproducing your fixture on the stub harness first (
The third one is the symptom you described, and it doesn't route through All three now key off the same signal: To keep the two class-attributing folds from both making up the difference, And your point that the differential fixture couldn't see it is fixed rather than worked around: On the Your third item is verified and named rather than fixed. A genuinely-new subject whose type and property both land in the window reports I added a The perf plan is filed as #1735, with your cache design intact — probe layer rather than assembled output, keyed Cost numbers re-measured at the new HEAD since the fixture changed: 10,008 flakes → 42.0ms (26.4×), 48,008 → 204.2ms (25.1×). |
…ures The merge with main was textually clean but the resolver tests construct IndexStats and PropertyStatEntry literals that main's #1728 added fields to. Values follow main's own test conventions: historical_since_t: None (no adoption boundary), observed_datatypes mirroring the entry's own tags, historical_datatypes empty.
Partially addresses #1391 — see What's left at the bottom for exactly what still drifts and why. (Started life as
Fixes; the residuals below are real enough that closing the issue on this would be overclaiming.)The mechanism
assemble_fast_stats_inner(fluree-db-novelty/src/runtime_stats.rs) folds novelty onto the indexed counts as a blind delta log —let delta = if flake.op { 1 } else { -1 };— with no idea what the base index already holds. Novelty's own set-semantics dedup (NoveltyFactState) is window-scoped by construction, so once a fact has been reindexed into the base and dropped from the window, asserting it again is kept as a fresh novelty flake, and that flake is charged a second+1on top of the base count it duplicates. The mirror case has the same root and nobody had noticed it:apply_commit's gate isif flake.op && …, which short-circuits, so a retraction is never examined and never dropped — a DELETE that matched nothing charged-1against a count that never included it.Consequence: any count rendered from those stats over- or under-reports until the next reindex. The issue framed this as planner-only imprecision, and for the planner it is — but two surfaces put those numbers in front of people.
apoc.meta.datais what LangChain-style tooling reads to learn a graph's schema, andfluree infoprints class/property counts as if they were facts about the ledger. On the fixture in the new test, one re-inserted document makesapoc.meta.datareportex:Widget ex:name = 6where a scan of exactly those facts returns5, andfluree inforeportex:Widgetinstance count6where a scan returns4.Worth flagging for anyone reading the issue's "self-corrects at the next reindex" line: the sweep that re-verified #1391 found that in the pure-duplicate case the per-class stats are wiped on the incremental reindex rather than settling back to truth. Separate mechanism, not fixed here.
The fix
New
NoveltyDeltaResolverinruntime_stats.rs. Drive it over a novelty walk inIndexType::Postorder and it hands back each flake's current-state delta instead offlake.op's sign.It folds presence, not ops. Per fact identity it carries the op that has won lifecycle resolution so far — the same rule
NoveltyFactState::recordapplies, highesttwins and at equalta retract beats an assert — and charges each flake the change in presence it causes. The first flake of an identity costs one base-index probe and chargesop − base_present. Those deltas telescope, so their sum over an identity is exactlyfinal_present − base_presenthowever many flakes the window holds and in whatever order they arrive.It deliberately does not assume kept flakes alternate assert/retract, because they don't.
apply_commit's gate short-circuits onflake.op &&, so it suppresses redundant asserts only; andcmp_postsortsopascending at equaltwhilefact_stateresolves a same-tpair retract-wins, so the losing assert reaches the fold last. Both(retract, retract)and(retract, assert, assert)are reachable — the second deliberately, sincebulk_apply_commitsreplays raw persisted flakes on cold load, which is exactly why the crate keepssame_t_assert_retract_keeps_later_reassertaround to pin that state as legal. All three shapes are regression tests ([0, -1],[0, 1, 1],[-1, 1, 1]), along with two same-tpairs back to back, where a fold that compared against the previous op rather than resolving presence credits a trailing assert that lost.The probe is one bounded SPOT scan with
sandpboth bound, cached per(graph, subject, predicate)for the assembly, read against an empty overlay at the indexedt— the question is strictly about the base, not about the merged view. It is skipped for commit-metadata flakes, whose subject is the commit's own CID digest: no base fact can share the identity, andinclude_in_runtime_statsdiscards them from every per-class and per-property count anyway. That was 7–10 guaranteed cache misses per commit in the window, buying nothing.That
thas to be the same one novelty was flushed to, or a partial flush leaves a retraction in the window whose matching assert has already moved into a base this probe reads as of an earliert— charging0where the truth is-1. It is, by construction: both production callers ofNovelty::clear_up_to(fluree-db-ledger/src/lib.rs) passnew_snapshot.t, so the cutoff and the snapshot installed alongside it are the same value, and both refuse a snapshot whosetis below the current indext. Recorded on the field rather than left to be re-derived.base_containsbinary-searches the cached facts, whichscan_basesorts on(o, dt).oanddtcompare withOrdbecause that is the ordering the search needs and the one the index comparators andNoveltyFactState's keys already use — not becauseOrdis stricter. It isn't:FlakeValue'scmpand itseqboth route cross-representation numerics throughnumeric_cmp, soLong(3)andDouble(3.0)compareEqualunder either. What keeps them from being one identity is thedttiebreak alongsideo.mis matched with==rather thancmp, since it isn't part of the sort key — it filters the block the search lands on.Deliberately the same shape as the indexer-side correction from
9d4026b32(StatsIdHook::on_record_with_base_presencevson_record): derive the count delta from the materialized state transition, not the raw op.NoveltyMerge::Reconciled { site }is wired on at the four surfaces that render counts to users —ledger_info.rs(both therealtime_property_detailsarm and the fast one),cypher_procedures.rsmerged_stats(the fourdb.*catalog shims), andcypher_procedures.rsmeta_data_rows, which had its own inlineif flake.op { 1 } else { -1 }and never went throughmerged_statsat all.siteis required rather than optional: a stamp label shared across entry points is useless as a routing assertion, because a must-fire check on it is satisfied by any reconciling assembly rather than by the one under test. The labels live as named constants instats_merge_site, so adding a reconciling surface is a visible edit there.assemble_fast_stats/assemble_full_statskeep their signatures and their existing (estimate) behavior; reconciliation is opt-in via the new*_withvariants.Presence is not attribution
A base-presence probe answers "is this fact in the base index?". Per-class attribution needs a different question — "did the base rollup count this fact under this class?" — and the two diverge on exactly one shape: a subject whose class membership is new in the window.
Take a subject imported untyped and then re-upserted whole with an
@type. Its name is base-present, so the fold charges it zero; but the base rollup filed that name under the classes the subject held at index time, and the new class was not one of them. The restatement is the only contribution that row could ever have, so charging it zero doesn't make the number low —apoc.meta.datadrops zero-count datatypes, so the row disappears. That is #1391's own shape, inverted, on the surfaces this change exists to fix.delta > 0on anrdf:typeflake is the signal, and it costs nothing extra: the resolver has already probed that flake. A restatement is charged+1under the classes its subject gained in this window and nothing under the ones it already had, so the class the base rollup already counted it under is not charged a second time.reconcile_credits_only_the_gained_class_not_the_ones_base_already_filedis the pin for that half — an indexedex:Employeethat also becomes aex:Personmust not have its name counted twice.Exactly one fold may make up the difference or the breakdown doubles instead, so
assemble_fast_stats_innernow takes aRestatedAttributionsaying whether a class-attributing second pass follows. The single-pass arm does the attribution from its intra-passrdf:typeside table; the two-pass assembly defers to the second pass, which resolves classes throughlookup_subject_classesand so doesn't depend on whererdf:typehappens to land in POST order.meta_data_rowsreaches the same answer from its own two passes — pass A drives the resolver to learn which memberships are new, pass B charges a restatement under those.None of this is reachable from the estimate lane: nothing resolves to zero there, so the gained-class table isn't even built.
The measurement
#1391 asked for this specifically, so: worst case by construction — every novelty flake a duplicate on its own
(s, p), so the probe cache misses on every one — release build, local file-backed ledger.~4.3µs per novelty flake, ~205ms at the ceiling
MAX_RECONCILED_NOVELTY_FLAKES = 50_000permits. Reproducible:cargo test --release -p fluree-db-api --features native --test it_fast_stats_1391_regression -- --ignored --nocapture.Two things that measurement surfaced. The cap works and is visible — a 50,008-flake window declines and the two lanes converge to the same time and the same (wrong) count. And this is a per-request metadata cost that needs a deliberately raised reindex threshold to reach at all: the default is low enough that
server_defaults.rsdescribes it as reindexing roughly every commit. Real windows also share(s, p)across values and carry new subjects, whose dictionary lookup misses cheaply, so they land well under the worst case.One thing worth knowing that this doesn't fix:
RangeProvider::rangeis synchronous and called from an async path withoutspawn_blocking, so that 205ms is blocking a runtime worker. It isn't new —ledger_info's existingassemble_full_statsalready driveslookup_subject_classesthrough the same synchronous provider on the same path — but the cost is larger now than it was.The differential test
fluree-db-api/tests/it_fast_stats_1391_regression.rsbuilds an indexed ledger, then a second commit mixing duplicates with real news (ex:w1restated verbatim;ex:w2restating type and name while adding one new name;ex:w3entirely new;ex:w4gaining a@typeit never had while restating an already-indexed name;ex:g1restating type and onepartOfedge while adding another), then a third commit with two retractions — one real, one matching nothing. Every(class, property)count is compared against a scan of exactly those facts.Four things keep it from passing for the wrong reason:
set_fast_paths_disabled(true)— and the two must agree. That pins the issue's read-path claim (query answers were never affected; set semantics make the duplicate idempotent at read time) and would catch a future COUNT lane that started trusting the drifted stats.reconciledon its own site label with at least the duplicate count the fixture plants (8 for the whole-window walks, 5 forapoc.meta.data, whose property pass countsrdf:typeseparately).db.propertyKeys()must dropex:size, whose every fact was retracted — that ismerged_stats' one count-sensitive output, and under a blind merge the duplicate re-assert cancels the real retraction and keeps it listed.Reverting all four
Reconciledcall sites produces 18 failures, and each site fails its own assertions when reverted alone (6 / 6 / 4 / 2, verified one at a time).Plus 16 unit tests in
runtime_stats.rsagainst a stub base index: duplicate assert → 0, new assert → +1, retract of an indexed fact → −1, retract of an absent fact → 0, the reassert-then-retract and retract-then-reassert folds, the three adversarial non-alternating runs, two consecutive same-tpairs, language tags and@listpositions as distinct identities, the per-(s,p)probe cache, cross-graph resolution, the estimate lane reading zero leaflets, and the three gained-class attribution cases (gained class gets the restatement; a restated type is not a gained one; an existing class isn't charged twice).No CLI-level assertion, deliberately. The API pin covers
fluree.ledger_info(&ledger_id).execute(), which is the exact callfluree-db-cli/src/commands/graph.rsmakes, and the CLI adds only rendering, which this PR doesn't touch — andfluree-db-cliisn't built by this PR's CI jobs, so a test there wouldn't gate the change anyway. A CLI test that pins rendered output is worth having on its own merits, not as a condition of this fix.Scope-outs
Deliberately not reconciled, each documented in place rather than left as a silent gap:
stats_cache.rs(the planner — reconciling there would make accumulating a window quadratic in its own size, and no COUNT answer reads it);policy_builder.rs(uncached per governed query, and enforcement reads property identity rather than counts);fluree_db_novelty::stats::current_stats(its throwaway genesis snapshot carries no range provider, so it cannot reconcile; also attributes every flake to graph 0);fluree-db-indexer/src/stats/{class_property,id_hook}.rs(on_recordis the replay-from-scratch lane where the raw op is the transition;class_property.rshas noon_recordat all — it has a base-seededfrom_priordriving blind±1, i.e. the #1391 shape, and what saves it is that its only consumer has no caller in the workspace, which is now noted on the function itself so nobody wiring it up later inherits the bug silently).One scope-out is worth arguing for rather than just listing. The
property_ref_onlytrust gate. The hazard is real and I traced the chain:merge_property_datatypesdrops any datatype whose merged count reaches zero, so a spurious-1can drop a predicate's last literal tag, flipref_onlyto true, and license foldingFILTER(?x = ?y)into a term-equality join where SPARQL value equality was required. But the repair has to land in the estimate lane, which is the planner hot path, and the two available shapes are both worse than the disease inside this PR: gating on empty novelty disables the fold for the ~50% of queries that carry novelty (filter_fold's own docstring cites BSBM BI-2 at ~28s → ~0.03s), and keeping base-observed datatype tags changes whatPropertyStatEntry.datatypesemits for every consumer that sums it (fast_count.rs:151,count_plan_exec.rs:2061,stats_view.rs:265,index_stats.rs:250). It's latent, pre-existing, and not demonstrated end to end. Changing a hot lane on an unproven mechanism is the error this change was written to avoid, so it's documented in place atstats_cache.rsand filed as #1721, with both candidate fix directions and a note that reconciling the planner lane is not one of them. Happy to be overruled.The
stats_cache.rscomment there is also corrected on two counts it previously got wrong: several COUNT lanes deliberately do not gate onfast_path_store(count_plan_exec.rs,count_rows.rs, both onallow_cursor_fast_path) — what makes them safe is that they read through an overlay-mergingBinaryCursor, not that they decline; and "these numbers only order joins" is false, sinceStatsView::property_ref_onlyfeedsfilter_fold's node-only soundness guard.policy_builder's comment likewise now says that "never reads a count" isn't "count drift can't matter" — the assembler prunes zero-count entries before policy sees them.What's left — why this is
Partially addressesReal residual miscounts after this change:
declined:novelty_too_large) but the number a user sees is still wrong.stats_cache) andpolicy_builderstill carry the drift by design, with the reasons written down. Defensible — fast-stats over-count: novelty asserts not reconciled against the base index #1391's own closing note says planning tolerates approximation — but it is the over-count the issue names.stats.sizeis stillindexed.size + novelty.size, charging a duplicate re-assert's bytes unconditionally. It's an estimate of storage rather than a fact count, butfluree infoprints it.LedgerState::current_stats()— public, documented as "the canonical way to get up-to-date statistics", still estimate-grade and structurally unable to reconcile. Two test callers in-repo today; fixing it properly means fixing its graph attribution too.class_property.rsretains the shape, unreachable today.assemble_full_statsattributes a property twice when its subject'srdf:typelands in the same novelty window: once from the first pass's intra-passgraph_subject_classesside table, once from the second pass'slookup_subject_classes+subject_props. One fact reports a class-property count of2under both merge modes, so this drifts a reconciled surface —fluree info's class→property block on the default arm. Pre-existing and untouched here; closing it means deciding which of the two passes owns class attribution, which is a larger change than reconciliation and wants its own PR. Named at the site.@typeand nothing else picks up an instance count but none of its indexed properties. Pre-existing under both merge modes — the estimate lane has the same gap, for the same reason — and not made worse here, but adjacent enough to the fix that it should be written down rather than discovered later.Closing #1391 on this would mean claiming counts no longer drift, and (1) alone makes that untrue. The issue should stay open against those residuals.
Gates
cargo test -p fluree-db-novelty— 90 green (22 inruntime_stats, incl. 16 new).cargo test -p fluree-db-api --test it_fast_stats_1391_regression— green; 18 failures with all fourReconciledsites reverted, and 6 / 6 / 4 / 2 when reverted one at a time.cargo test -p fluree-db-api --features native— all binaries green (grp_query422,grp_ledger145,grp_index89,grp_misc262,grp_policy96,grp_transact188,grp_import99,grp_query_sparql349, lib 775, and the rest).cargo test -p fluree-db-query --lib1421 green;cargo test -p fluree-db-indexer --lib380 green.cargo clippy -p fluree-db-novelty -p fluree-db-api --features fluree-db-api/native --all-targets --no-deps— clean.cargo fmt --all— clean, run last.