Skip to content

Hierarchical precision-graph prior: de-gate, and deploy the surface across its validated shape range - #193

Open
MaartenMarsman wants to merge 11 commits into
developfrom
feature/hier-gating
Open

Hierarchical precision-graph prior: de-gate, and deploy the surface across its validated shape range#193
MaartenMarsman wants to merge 11 commits into
developfrom
feature/hier-gating

Conversation

@MaartenMarsman

Copy link
Copy Markdown
Collaborator

De-gates the hierarchical precision-graph prior: the trust gauge ships on, the
joint spec reports its realized edge prior, the size hull reaches the blocks a
real fit meets, and the absolute-moment surface is validated and actually
deployed
across a Gamma diagonal shape range rather than at the exponential
shape alone.

Working notes: dev/plans/active/2026-07-31_hier-gating_NOTE.md (measurements,
failures, and the reusable checklist). Validation artifacts and their
regeneration scripts are under dev/validation/.

The defect this branch exists to have found

ZRatioEngine::log_zratio re-derived the deployment policy the R layer already
owned, with an alpha == 1 test left over from the release in which the surface
served the exponential shape only. When the validated range widened, the two
layers disagreed silently: a fit built, cached and attached a surface, and was
then served the coarser additive kernel. MEASURED on a dense common-neighbour
block of 30 variables at shape 2, rate 2 — served 0.38610136 against a surface
value of 0.31794560, a gap of 0.068 nats, some twenty times the accuracy the
surface is stated to hold.

It survived every review pass because a test asserted it as the contract
("alpha != 1 fences log_zratio to the additive path"). A wrongly-tested path
looks safe in review in a way an untested path does not. Both the test and the
gate are replaced; the policy now has a single owner in R, and the engine
deploys whatever it is attached.

Every accuracy figure in the program had been scored on the surface path
(logR) while a fit takes the hot path (log_zratio). Those numbers were true
and none of them exercised the deployed route. That is now certified separately.

Deployed-route certification

Identity: log_zratio equals logR exactly (max difference 0) across 36
cells — shapes {0.5, 1, 2, 3, 5, 10} x both component families x block sizes
{10, 24, 42}.

End-to-end gold through log_zratio (rate 2, common-neighbour block of 42):

shape gold gold sd deployed deployed error additive error
0.5 0.3679 2.1e-04 0.3678 5.9e-05 0.368
1 0.3431 4.4e-04 0.3426 5.3e-04 0.343
2 0.2895 2.7e-04 0.2883 1.2e-03 0.173
3 0.2353 1.8e-04 0.2350 3.3e-04 0.0056
5 0.1469 5.6e-05 0.1467 2.4e-04 0.0226
10 0.0477 9.4e-07 0.0477 4.7e-06 0.0477

Every deployed error is inside the 0.003-nat envelope. The additive column is
what a fit received before this branch at every shape except 1.

Shape range: two claim types, kept apart

Accuracy claims — scored against block-Gibbs gold, worst error over each
interior band, both anchor-build seeds pooled:

shape rate 1 cn rate 1 bip rate 2 cn rate 2 bip worst additive
0.5 0.0015 0.0014 0.0013 0.0027 0.401
1 0.0010 0.0012 0.365
2 0.00057 0.00019 0.00124 0.00107 0.215
3 0.00042 0.00017 0.00080 0.00015 0.115
5 0.00010 0.0000125 0.00038 0.000072 0.0226

All inside the 0.003-nat envelope. The reference resolves these comparisons:
the gold span across each band runs 61x to 223x its own noise.

Irrelevance claim (shape 10) — a bound, not an approximation. The diagonal
has concentrated far enough that the entire mediated correction is at most
2.8e-04 nats across 22 scored cells (both families, both rates, sizes 20 to
100), so any method returning the isolated-edge value is wrong by at most that.
The bound is set at rate 2, since the correction grows with the rate, and is
scoped to rate <= 2. Mediation magnitude by shape: 0.307, 0.161, 0.0408,
2.8e-04 at shapes 2, 3, 5, 10.

The range moved because the anchor simulator was replaced, not because the
surface changed: at a non-unit shape the old independence-Metropolis pivot
collapsed (about 1% acceptance at shape 5) and the chain froze while still
reporting moments — a confidently wrong answer rather than a noisy one. The
slice-sampled replacement has no acceptance step in that coordinate and its
efficiency per sweep is flat in shape and block size. The exponential-shape path
is unchanged and bit-identical, seed for seed.

Constants layer

Two independent fixes, both user-invisible in magnitude and both real.

  1. Pair table truncation. The table ended at a smaller argument than the
    saddle integration that consumes it, and the interpolator clamps an
    out-of-range read to its edge value, so 22 units of the integration carried a
    plateau instead of the integrand's decay. Present in released bgms. Moves
    the per-edge log ratio by at most 4.3e-07 nats. The two grid domains now
    assert their compatibility where they meet.
  2. Theta quadrature. A larger shape concentrates the inner integrand into a
    narrow spike the fixed rule stopped resolving: error at rate 1 ran 2.4e-06 at
    shape 10, 2.4e-04 at 15 and 3.9e-03 at 20. Doubling the rule restores
    agreement to 1e-07 or better through shape 20 for about +0.4 s on a
    once-per-fit build. The Laguerre axis was not the constraint (1e-15 at shape
    20).

The engine reference fixture is regenerated through a committed script that
encodes the per-cell parameters, freezes the cells the mapping does not apply
to and checks the freeze through the round trip, and distinguishes a tail-only
change from a quadrature one.

Also in this branch

  • Trust gauge on by default, silent when clean, with a remediation ladder that
    no longer offers a different inferential target as first-line advice. Cost is
    fixed per chain, not proportional to iter: nothing on a sparse posterior,
    about 5-10 s on a dense large-q one.
  • Joint-spec realized-prior notice: under edge selection on a continuous block
    the realized graph marginal is the edge prior reweighted by the per-graph
    normalizer, not the nominal prior.
  • Size hull: anchor at the cap (a cap between grid tiers trained the hull at the
    lower tier), range raised to 80, and prediction past the hull continues along
    the fitted boundary slope instead of freezing — freezing's error grows without
    bound in block size. Extrapolation counters split warmup from retained.
  • One-time cached build stays inside its budget: 24.5 s at the exponential
    shape, 43-47 s at a non-unit one, single-core, at the full 80-variable range.

Gates

Measured locally on the pre-rebase tree:

  • Local tier: FAIL 0 | WARN 0 | SKIP 96 | PASS 7469
  • R CMD check --as-cran: 0 errors, 0 warnings, 1 NOTE (pre-existing: the
    DESCRIPTION Date field is over a month old)

The branch is rebased onto current develop, so CI is the authority on the
merged tree.

Deliberately not included

  • Routing past the deployed range to the isolated-edge value. Beyond shape
    10 the additive path still serves, unchanged from the released package and
    documented as a limitation. The design is settled (mediation-off flag plumbed
    R to engine to interface to chain runner, one rule for both families, rate
    <= 2 with a notice beyond) and is scoped as a follow-up rather than landed on
    a depleted budget — the failure mode this branch spent a session diagnosing is
    precisely a flag honoured in one layer and ignored in another.
  • A mechanism for the additive kernel's zero-collapse on common-neighbour
    blocks. It returns approximately zero at the exponential shape too, so the
    "progressive in shape" reading is wrong and no mechanism claim appears
    anywhere. The exposure numbers stand on their own measurement.

…ation

Under precision_graph_prior = "joint" the graph marginal is the edge prior
reweighted by the per-graph normalizer, not the edge prior itself. A fit with
edge selection on a continuous precision block now says so once, before
sampling, with separate wording for a fixed Bernoulli prior (the realized
inclusion probability differs from the nominal one) and for a learned
Beta-Bernoulli or Stochastic-Block prior (the learned parameters are
themselves reweighted).

The notice follows bgms.verbose and does not change the sampler.

Note: dev/plans/active/2026-07-31_hier-gating_NOTE.md (WP1).
…on ladder

The gauge is a post-sampling diagnostic whose cost is fixed per chain rather
than proportional to iter: two assessment sweeps over a capped number of edge
moves, which is nothing on a sparse posterior (no mediating block is
non-trivial, the normalizer ratio is exact) and 5-10 s per chain on a dense
posterior at 100-200 variables. options(bgms.zratio_gauge_sweeps = 0L) is the
off switch.

The advice printed for a flagged chain is now a ladder: report what was
measured (error share, the mediating-block sizes the audit covered via the new
per_chain block_lo/block_hi, how many non-trivial moves were audited), then
raise the sweeps and refit to resolve whether the signal is real, and only
then mention precision_graph_prior = "joint" -- stating that it targets a
different model. The gauge never switches specification on its own.

The Rd and the diagnostics vignette document the audit as a sample: it
resolves coherent error, which is what harm_pred projects, and does not
resolve rare edge-specific failures. Silence on a sparse posterior is
exactness, not blindness.

Note: dev/plans/active/2026-07-31_hier-gating_NOTE.md (WP0 benchmark, WP2).
The anchor grid was filtered to tiers at or below the cap, so a cap landing
between two tiers trained the hull at the lower one and extrapolated
everything above it: at a cap of 40 the size-42 tier dropped out and the hull
stopped at 36. zratio_cap_tier appends a tier at the cap when the surviving
grid stops more than two sizes short of it -- two because a mediating block
excludes the edge's own endpoints and so never exceeds cap - 2.

The grids move into zratio_anchor_grids as the single source for both
families, with the sweep budget set by size through zratio_anchor_sweeps. The
cache key gains a version tag so a surface built by an earlier grid is not
served to this one.

Note: dev/plans/active/2026-07-31_hier-gating_NOTE.md (WP3a).
…ndary slope

The anchored size range reaches 80 variables, up from 44, with tiers at 52,
64 and 80 (common-neighbour) and 30, 38, 52, 64 and 80 (bipartite). The
one-time build stays at 24 s serial. The interior was re-scored before the
raise: the wider fit costs the small-size bands 0.0002 -> 0.0004 nats median,
inside the 0.003 envelope.

Past the range the prediction now continues along the surface's own boundary
slope in log-size instead of freezing at the edge. Freezing left the
prediction fixed while the truth kept moving, so its error grew without bound
in block size. Scored against block-Gibbs gold at sizes 90-150 against a
size-80 hull, over two anchor-build seeds: the tangent holds a median 0.0006
nats and at most 0.0011 (common-neighbour) and a median 0.0043 and at most
0.0060 (bipartite), where freezing reaches 0.060 and 0.095 and the additive
fallback 0.21 and 0.31. Continuing the fitted quadratic as its own
extrapolant was measured and rejected: it degrades with distance (0.0026 and
0.016 over the same band) where the tangent stays flat.

The absolute moments grow with component size, so a fitted edge slope
pointing downward is a fit pathology. It is floored at zero -- which
reproduces the old freeze -- and counted, since a silent per-density-band
degeneracy is what the gold scoring above would not catch.

Note: dev/plans/active/2026-07-31_hier-gating_NOTE.md (WP3b, WP3c).
The sampler initializes from a complete graph, so warmup alone can put every
mediating block past the anchored range while the retained draws stay well
inside it. A whole-run share therefore reported a transient as if it
described the posterior. The engine now tallies per phase: chain_runner sets
Warmup, switches to Retained before the first retained iteration's edge
selection, and sets Gauge around the assessment sweeps, which are extra
deploy evaluations no stored draw comes from and enter neither tally.

The notice reports the retained share, the largest retained block, and the
measured accuracy of the extension there, with the warmup share in brackets.
Counters that carry no split (an older fit object) report the whole-run share
and make no phase claim -- routing them to the retained wording would
manufacture reassurance the output does not contain.

Note: dev/plans/active/2026-07-31_hier-gating_NOTE.md (WP3d).
…s 0.5, 1, 2

The surface was fenced to the exponential diagonal. It is now validated at
three gamma_prior() diagonal shapes -- 0.5, 1 and 2 -- and deploys on the
range they span; the interior is interpolated, not measured, and the fence
message and docs state the claim as three scored points.

Constants first: the generalized Gauss-Laguerre quadrature carrying the shape
was certified against brute-force integration before any surface was scored,
including at the shape below 1 where the diagonal prior is singular at the
origin. Then the eight cells (shape 0.5, 2 x eta 1, 2 x both families) against
block-Gibbs gold over two anchor-build seeds: median 0.0001-0.0013 nats, at
most 0.0027, against 0.016-0.40 for the additive kernel; the boundary-slope
tail spot-checked at size 100 in every cell (0.0001-0.0059).

Fixed: zratio_anchor_cn and zratio_anchor_bip did not pass the cell's Gamma
shape to the block-Gibbs oracle, so anchors were drawn at the exponential
while the constants carried the cell's own. Dormant behind the fence, and it
would have made every shape result unattributable. The regression test runs
unfenced at a small budget.

The upper end is set by the anchor oracle, not the surface: at a non-unit
shape the oracle's row update is an independence-Metropolis step whose
acceptance falls away from shape 1 (20 nodes, density 0.9: 80% at shape 2,
70% at 2.5, 60% at 3, 35% at 4, 1-12% at 5), and at shape 5 the anchor
Monte-Carlo error runs 50-500x the shape-1 reference and is not restored by
94x the sweeps. Anchors off shape 1 run at a raised budget, resolved by
matching measured anchor spread rather than by 1/acceptance.

The block-Gibbs gold references are tracked under dev/validation so later
work scores against the same bank; dev stays out of the build via
.Rbuildignore.

Note: dev/plans/active/2026-07-31_hier-gating_NOTE.md (WP4).
…update

At a non-unit Gamma diagonal shape the block oracle drew the off-diagonals and
the diagonal pivot jointly from the alpha = 1 conjugate and accepted or
rejected the pair with weight K_ii^(alpha-1). That weight scales with the
diagonal, so acceptance collapsed as the shape grew (measured 80% at shape 2,
1-12% at shape 5) and the whole row froze with it.

The row is now two blocks: an accept/reject on the off-diagonals at a held
pivot, whose ratio carries only the quadratic form it moves, followed by an
exact slice update of the pivot conditional
    pi(xi) ∝ xi^delta exp(-beta xi) (xi + q)^(alpha - 1).
There is no acceptance left on the pivot to collapse. Prototype measurement:
pivot ESS per sweep stays 0.59-1.00 over shapes 0.5 to 10 and blocks of 4 to
80, where the old kernel reaches zero from shape 5 and blocks of 20 up.

Slice rather than adaptive rejection because log-concavity is not
unconditional: curvature is -delta/xi^2 - (alpha-1)/(xi+q)^2, so below shape 1
concavity needs delta >= 1 - alpha. At delta = 0.2, alpha = 0.5 the curvature
flips at xi = 1.72 with 3.2% of the target beyond it. That cell is pinned in
the suite so a later proposal that assumes concavity fails against the case
that proves it cannot.

The pivot is recomputed as K_ii - b' C b against the C in hand at every row
update, never carried forward: C moves whenever a neighbouring row updates,
and a stored pivot silently targets the wrong law (caught in the prototype
against an independent random-walk reference, |z| up to 22).

At alpha = 1 nothing changes. There is no coupling, the row stays a direct
Gibbs draw, and the RNG call order is unchanged, so the draw stream is
identical; a test pins the moments to 1e-15 against values from the pre-split
kernel. Cross-checked against that kernel at shapes 0.5 and 2, where it was
validated and still mixes: moments agree to 0.34% relative, max |z| = 1.3 over
five seeds. The slice path itself is scored against the conjugate path it
replaces by running a shape a hair off 1 (max |z| = 1.6).

Slice cap hits are counted and exported alongside the other engine counters;
measured zero everywhere, including the non-log-concave cell.

Note: dev/plans/active/2026-07-31_hier-gating_NOTE.md (WP5 rung 2).
At alpha = 1 + 1e-9 the split branch is taken while the pivot conditional
collapses onto the alpha = 1 conjugate law, so the branch split, the
held-pivot accept step and the slice sampler are all scored against the
bit-identical kernel rather than against a second approximation. It is the
only check in the file with that property, so it should not sit behind the
slow gate; trimmed to three seeds and 2500 sweeps to keep it a few seconds.
The pair integrals are tabulated on [0, cmax] and interpolated; a read
past cmax clamps to the edge value. zratio_saddle_grid integrates them
to Cmax = 40 against a table that ended at cmax = 18, so 22 units of the
saddle integrand carried a plateau in place of the integrand's decay.

cmax 18 -> 42 with ngrid 121 -> 281 holds the node spacing at 0.15, so
the range is appended rather than re-spread and all 121 original nodes
are bit-identical. zratio_saddle_grid now asserts that the table covers
the range it reads, since the clamp is invisible at the call site.

MEASURED at the two sigma = 1 reference cells: ihat is unchanged to the
bit, ghat moves at most 2.2e-5 absolute (1.8e-6 relative to its head
value), and the per-edge log Z-ratio moves at most 4.3e-7 nats. The head
shift equals the spurious plateau rectangle net of the true tail mass to
seven digits, which identifies the shift as the plateau rather than a
change in the integration. Against the 0.003-nat envelope the surface
claims are stated in, no recorded validation result changes. The plateau
is present in released bgms.

The fixture is regenerated for cells 1 and 4 only: the constant builder
works in the standardized cell, so it reproduces a stored cell only where
sigma = 1, and cells 2 and 3 carry sigma = 2. Those two are frozen and
asserted byte-identical through the round trip. evals holds engine
log-ratios computed against ihat/ghat, so it is rebuilt in the same pass.
The regeneration is a committed dev script with the per-cell parameters
encoded and a --check mode that verifies without writing.

Local tier: FAIL 0 | WARN 0 | SKIP 96 | PASS 7462.
The engine ignored the surface at every non-unit Gamma diagonal shape.
zratio_build_surfaces built, cached and attached one across the validated
range, and log_zratio then re-tested the shape itself with an alpha == 1
condition left over from the release in which the surface served the
exponential shape only. The two layers disagreed silently: a fit paid for
a surface and was served the additive kernel. MEASURED on a dense
common-neighbour block of 30 at shape 2, eta 2: served 0.38610136 against
a surface value of 0.31794560, 0.068 nats, 23x the stated envelope.

The deployment policy now has one owner. R decides which cells get a
surface; the engine deploys whatever it is attached and does not
re-derive the range. A duplicated fence is not defence in depth.

A test was pinning the defect as a contract ("alpha != 1 fences
log_zratio to the additive path"), which is how it cleared review: a
passing assertion reads as evidence the behaviour was intended. Replaced
with the correct contract, plus a deployment probe asserting the value a
fit receives EQUALS the surface value rather than merely differing from
the additive one.

Shape range 2 -> 10. Scored against block-Gibbs gold at 0.5, 1, 2, 3 and
5, every interior cell inside 0.003 nats; at 10 the guarantee is a bound
rather than an approximation, the whole mediated correction being at most
2.8e-04 nats over the scored band at eta <= 2. Interior interpolated.

Theta quadrature nleg 64 -> 128. A larger shape concentrates the inner
integrand into a narrow spike the fixed rule stopped resolving: MEASURED
error in the tabulated constants at eta 1 ran 2.4e-06 at shape 10,
2.4e-04 at 15 and 3.9e-03 at 20. At 128 every shape through 20 agrees
with a converged rule to 1e-07 or better, for +0.4 s on a once-per-fit
build. The Laguerre axis was not the constraint (1e-15 at shape 20).
The fixture is regenerated through its committed script, second
regeneration; the script's static-move guard fired at 6.1e-11 and now
distinguishes a tail-only change from a quadrature one.

Deployed-route certification: log_zratio equals logR exactly across 36
cells (6 shapes x 2 families x 3 sizes), and an end-to-end gold spot per
shape through log_zratio lands at 4.7e-06 to 1.2e-03 against golds whose
sd is 1e-06 to 4e-04, every one inside the envelope.

Not included: routing past the deployed range to the isolated-edge value.
Beyond shape 10 the additive path still serves, unchanged from the
released package and now documented as a limitation.

Local tier: FAIL 0 | WARN 0 | SKIP 96 | PASS 7469.
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.

1 participant