Skip to content

fix(stats): reconcile novelty asserts against the base index for user-facing counts - #1699

Merged
aaj3f merged 7 commits into
mainfrom
fix/fast-stats-novelty-overcount
Aug 28, 2026
Merged

aaj3f merged 7 commits into
mainfrom
fix/fast-stats-novelty-overcount

Conversation

@aaj3f

@aaj3f aaj3f commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

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 +1 on top of the base count it duplicates. The mirror case has the same root and nobody had noticed it: apply_commit's gate is if flake.op && …, which short-circuits, so a retraction is never examined and never dropped — a DELETE that matched nothing charged -1 against 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.data is what LangChain-style tooling reads to learn a graph's schema, and fluree info prints class/property counts as if they were facts about the ledger. On the fixture in the new test, one re-inserted document makes apoc.meta.data report ex:Widget ex:name = 6 where a scan of exactly those facts returns 5, and fluree info report ex:Widget instance count 6 where a scan returns 4.

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 NoveltyDeltaResolver in runtime_stats.rs. Drive it over a novelty walk in IndexType::Post order and it hands back each flake's current-state delta instead of flake.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::record applies, highest t wins and at equal t a 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 charges op − base_present. Those deltas telescope, so their sum over an identity is exactly final_present − base_present however 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 on flake.op &&, so it suppresses redundant asserts only; and cmp_post sorts op ascending at equal t while fact_state resolves a same-t pair retract-wins, so the losing assert reaches the fold last. Both (retract, retract) and (retract, assert, assert) are reachable — the second deliberately, since bulk_apply_commits replays raw persisted flakes on cold load, which is exactly why the crate keeps same_t_assert_retract_keeps_later_reassert around to pin that state as legal. All three shapes are regression tests ([0, -1], [0, 1, 1], [-1, 1, 1]), along with two same-t pairs 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 s and p both bound, cached per (graph, subject, predicate) for the assembly, read against an empty overlay at the indexed t — 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, and include_in_runtime_stats discards 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 t has 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 earlier t — charging 0 where the truth is -1. It is, by construction: both production callers of Novelty::clear_up_to (fluree-db-ledger/src/lib.rs) pass new_snapshot.t, so the cutoff and the snapshot installed alongside it are the same value, and both refuse a snapshot whose t is below the current index t. Recorded on the field rather than left to be re-derived.

base_contains binary-searches the cached facts, which scan_base sorts on (o, dt). o and dt compare with Ord because that is the ordering the search needs and the one the index comparators and NoveltyFactState's keys already use — not because Ord is stricter. It isn't: FlakeValue's cmp and its eq both route cross-representation numerics through numeric_cmp, so Long(3) and Double(3.0) compare Equal under either. What keeps them from being one identity is the dt tiebreak alongside o. m is matched with == rather than cmp, 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_presence vs on_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 the realtime_property_details arm and the fast one), cypher_procedures.rs merged_stats (the four db.* catalog shims), and cypher_procedures.rs meta_data_rows, which had its own inline if flake.op { 1 } else { -1 } and never went through merged_stats at all. site is 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 in stats_merge_site, so adding a reconciling surface is a visible edit there.

assemble_fast_stats / assemble_full_stats keep their signatures and their existing (estimate) behavior; reconciliation is opt-in via the new *_with variants.

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.data drops zero-count datatypes, so the row disappears. That is #1391's own shape, inverted, on the surfaces this change exists to fix.

delta > 0 on an rdf:type flake is the signal, and it costs nothing extra: the resolver has already probed that flake. A restatement is charged +1 under 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_filed is the pin for that half — an indexed ex:Employee that also becomes a ex:Person must not have its name counted twice.

Exactly one fold may make up the difference or the breakdown doubles instead, so assemble_fast_stats_inner now takes a RestatedAttribution saying whether a class-attributing second pass follows. The single-pass arm does the attribution from its intra-pass rdf:type side table; the two-pass assembly defers to the second pass, which resolves classes through lookup_subject_classes and so doesn't depend on where rdf:type happens to land in POST order. meta_data_rows reaches 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.

novelty= 10,008 flakes   estimate 1.6ms   reconciled  42.0ms   (26.4x)
novelty= 48,008 flakes   estimate 8.1ms   reconciled 204.2ms   (25.1x)

~4.3µs per novelty flake, ~205ms at the ceiling MAX_RECONCILED_NOVELTY_FLAKES = 50_000 permits. 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.rs describes 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::range is synchronous and called from an async path without spawn_blocking, so that 205ms is blocking a runtime worker. It isn't new — ledger_info's existing assemble_full_stats already drives lookup_subject_classes through 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.rs builds an indexed ledger, then a second commit mixing duplicates with real news (ex:w1 restated verbatim; ex:w2 restating type and name while adding one new name; ex:w3 entirely new; ex:w4 gaining a @type it never had while restating an already-indexed name; ex:g1 restating type and one partOf edge 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:

  1. Every count is asserted against both a hand-pinned number and a scan, so a generic-pipeline regression fails as loudly as a fast-stats one.
  2. Everything runs twice — fast paths on, then under 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.
  3. Per-surface must-fire stamps. Each of the four reconciling sites must stamp reconciled on its own site label with at least the duplicate count the fixture plants (8 for the whole-window walks, 5 for apoc.meta.data, whose property pass counts rdf:type separately).
  4. db.propertyKeys() must drop ex:size, whose every fact was retracted — that is merged_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 Reconciled call 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.rs against 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-t pairs, language tags and @list positions 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 call fluree-db-cli/src/commands/graph.rs makes, and the CLI adds only rendering, which this PR doesn't touch — and fluree-db-cli isn'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_record is the replay-from-scratch lane where the raw op is the transition; class_property.rs has no on_record at all — it has a base-seeded from_prior driving 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_only trust gate. The hazard is real and I traced the chain: merge_property_datatypes drops any datatype whose merged count reaches zero, so a spurious -1 can drop a predicate's last literal tag, flip ref_only to true, and license folding FILTER(?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 what PropertyStatEntry.datatypes emits 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 at stats_cache.rs and 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.rs comment there is also corrected on two counts it previously got wrong: several COUNT lanes deliberately do not gate on fast_path_store (count_plan_exec.rs, count_rows.rs, both on allow_cursor_fast_path) — what makes them safe is that they read through an overlay-merging BinaryCursor, not that they decline; and "these numbers only order joins" is false, since StatsView::property_ref_only feeds filter_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 addresses

Real residual miscounts after this change:

  1. Above the 50k cap, the fixed surfaces revert to the blind delta log and drift exactly as before. The stamp records it (declined:novelty_too_large) but the number a user sees is still wrong.
  2. The planner lane (stats_cache) and policy_builder still 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.
  3. stats.size is still indexed.size + novelty.size, charging a duplicate re-assert's bytes unconditionally. It's an estimate of storage rather than a fact count, but fluree info prints it.
  4. 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.
  5. class_property.rs retains the shape, unreachable today.
  6. assemble_full_stats attributes a property twice when its subject's rdf:type lands in the same novelty window: once from the first pass's intra-pass graph_subject_classes side table, once from the second pass's lookup_subject_classes + subject_props. One fact reports a class-property count of 2 under 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.
  7. A class gained in the window attracts only the facts the window carries. The gained-class rule above can credit a restatement, because a restatement is a flake in the window. A base fact that isn't restated has no flake to attribute, so a subject that gains an @type and 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 in runtime_stats, incl. 16 new).
  • cargo test -p fluree-db-api --test it_fast_stats_1391_regression — green; 18 failures with all four Reconciled sites reverted, and 6 / 6 / 4 / 2 when reverted one at a time.
  • cargo test -p fluree-db-api --features native — all binaries green (grp_query 422, grp_ledger 145, grp_index 89, grp_misc 262, grp_policy 96, grp_transact 188, grp_import 99, grp_query_sparql 349, lib 775, and the rest).
  • cargo test -p fluree-db-query --lib 1421 green; cargo test -p fluree-db-indexer --lib 380 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.

…-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.
@aaj3f aaj3f closed this Aug 26, 2026
@aaj3f aaj3f reopened this Aug 26, 2026
…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 bplatz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread fluree-db-novelty/src/runtime_stats.rs Outdated
if !include_in_runtime_stats(flake, to_t) {
continue;
}
if delta == 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread fluree-db-novelty/src/runtime_stats.rs Outdated
/// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@aaj3f

aaj3f commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor Author

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 (deltas = [type: +1, name: 0] → Reconciled Person = (count 1, [("name", [])])), then tracing outward:

fold surfaces pre-fix
assemble_full_stats_with pass 2 (runtime_stats.rs:233) ledger-info-full, the default fluree info arm [] vs truth 1
assemble_fast_stats_inner pass 1, intra-pass class table ledger-info-fast, merged-stats [] vs truth 1
meta_data_rows pass B (cypher_procedures.rs) apoc.meta.data row absent entirely

The third one is the symptom you described, and it doesn't route through runtime_stats at all — it has its own source-1/source-2 merge. So fixing only line 233 would have left apoc.meta.data still dropping the row. Worth knowing for the next one of these.

All three now key off the same signal: delta > 0 on an rdf:type flake means the class is new relative to base, so the base rollup has no row to double and a restatement is charged +1 under gained classes only. The per-class filter is load-bearing and I pinned it — a subject that is an indexed ex:Employee and becomes an ex:Person must have its restated name credited to Person and not re-credited to Employee. The naive version of this fix ("stop skipping delta == 0") re-introduces the over-count on the base class, which is #1391 again in miniature.

To keep the two class-attributing folds from both making up the difference, assemble_fast_stats_inner takes a private RestatedAttribution: the single-pass arm does it from its intra-pass table, the two-pass assembly defers to pass 2, which resolves classes through lookup_subject_classes and is therefore independent of where rdf:type lands in POST order. The estimate lane is behaviourally identical and doesn't even build the gained-class table.

And your point that the differential fixture couldn't see it is fixed rather than worked around: ex:w4 now imports untyped in the base commit and gains @type + restates its name in the novelty commit. Without the fix that fails end to end — apoc.meta.data reports ex:Widget ex:name = 4; a scan returns 5.

On the base_contains comment — your main point is right and it's now the comment's spine: both relations route cross-variant numerics through numeric_cmp, so they're the same relation there and the dt tiebreak is what separates Long(3) from Double(3.0). One correction though, and I checked it by execution rather than reading: the NaN case goes the other way. FlakeValue::eq routes all numerics through numeric_cmp, whose Double arm falls back to to_bits().cmp(), so Double(NaN) == Double(NaN) is true — Ord and Eq agree there too, and neither charges a NaN re-assert as a duplicate. So I left the NaN example out of the comment entirely rather than write an inverted one. (Same conclusion as the NaN footnote on #1698, for what it's worth — the two relations agree on that value in both directions.) The comment also now says why m pairs with ==: it isn't in the sort key.

Your third item is verified and named rather than fixed. A genuinely-new subject whose type and property both land in the window reports [(0, 2)] for one fact under both merge modes — pass 1 attributes off its intra-pass table, pass 2 attributes again off lookup_subject_classes + subject_props. Scoped precisely: assemble_full_stats only, so the drifting surface is fluree info's class→property block on the default arm; apoc.meta.data never calls that assembler. It's residual #6 in What's left, and named at the site in code.

I added a #7 next to it, adjacent and pre-existing: a class gained in the window only attracts facts the window carries. A restatement can be credited because it's a flake; a base fact that isn't restated has none — so a subject that gains an @type and nothing else picks up an instance count but no properties. Same under both modes. Better written down beside the fix than mistaken for a hole in it later.

The perf plan is filed as #1735, with your cache design intact — probe layer rather than assembled output, keyed (snapshot.t, novelty.epoch), and both reasons that makes it the right layer (to_t-independence sidestepping the i64::MAX vs ledger.t() split, and policy-independence letting one cache serve meta_data_rows under any enforcer). Including your note that the predicate-presence skip's win is smaller than it looks because a novelty-only-predicate probe already short-circuits inside the provider, and the separate spawn_blocking point, which the cache doesn't help.

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×).

@aaj3f aaj3f closed this Aug 28, 2026
@aaj3f aaj3f reopened this Aug 28, 2026
aaj3f added 3 commits August 28, 2026 12:16
…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.
@aaj3f
aaj3f merged commit 33f2de3 into main Aug 28, 2026
14 checks passed
@aaj3f
aaj3f deleted the fix/fast-stats-novelty-overcount branch August 28, 2026 16:36
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