Skip to content

Rules that turned a refusal into a silent fallback (#22, #25, #27) #32

Description

@chenyueqi

Review of #22, #25 and #27 (2026-09-04) found rules that turned a refusal on main into a silent fallback on the branch. Each raises the count of units that reach the gate, but none of them translates anything new: what main reported as a refusal is now either a value the engine assumed, or a failure the evidence no longer names. The bit-exact gate is the backstop, and it only sees the paths the sampled trials exercise; a tolerance constant folded at the wrong kind, or an array logical spelled as a scalar not over a length-1 array, can pass every trial and still be wrong.

These are recorded here, apart from the review comments, so they are ruled on one at a time and none is merged by omission. A row closes when the rule refuses again, records what it assumed in the evidence, or takes the assumption from configuration the extension supplies.

# PR Where main branch How a wrong result passes
1 #22 src/recast/fortran/constants.py:277-280 a kind inquiry whose argument names nothing known returns None, the constant is skipped returned as a call with no argument, spelled np.finfo(np.float64).eps real(4) :: x then real(8), parameter :: e = epsilon(x) folds to 2.2e-16 (gfortran 1.19e-7); a tolerance rarely changes a sampled output
2 #22 src/recast/fortran/expr.py:88-91, 288-290 and use.py:86 intrinsic calls in initializers refused outright epsilon/huge/tiny/real/dble fold at 64 bits whatever the declared kind; the self-reference is replaced by a 64-bit literal real(4), parameter :: tol = max(1e-10, epsilon(tol)) folds to the 64-bit epsilon
3 #22 src/recast/fortran/expr.py:126-135 real(x) refused explicit kind=sp refused, bare real(x) (also single precision) passes as np.float64 full double precision kept where gfortran rounds to single
4 #22 src/recast/fortran/expr.py:50 no domain kind names in the engine KINDS_64 lists core_rknd, shr_kind_r8 as 64-bit CLUBB's core_rknd is a preprocessor choice and can be 4; the engine has kind_assumptions for exactly this
5 #25 src/recast/transform/numpy/expressions.py:424-427 (same as branch: not for every non-WHERE operand) ~/&/| when rank > 0, scalar spelling when rank cannot be settled or the name is undeclared (semantics.rank returns 0 without raising) not np.array([True]) is False, silently; only len > 1 raises
6 #25 src/recast/fortran/interface.py:588-603 bounds naming a local parameter left as text; wrapper fails to compile bare name folded; -nd:nd folds the upper bound only and leaves - nd in the record, nothing refuses it the wrapper still fails at gfortran, so loud today, but the half-folded record is what the sampler reads
7 #27 src/recast/transform/numpy/statements.py:1752-1760 a call to a stubbed module's procedure that no stub answers is NoRule: block deferred, counted, in the evidence rendered as raise NotImplementedError(...); block status mechanical, absent from deferred, the gate judges the subprogram the deferred count falls with no translation done; another input that takes the path is a runtime failure with no note in the verdict
8 #27 src/recast/fortran/flatten.py:1076-1099 private module state referenced by the planned subprogram refuses the plan recorded under left_to_module, plan continues, "both sides run with the module's own default" the recorded reference run may have set it through a public setter (set_clubb_debug_level writes error_code%clubb_debug_level)
9 #27 src/recast/oracle/record.py:305-312 every non-derived array dummy with intent other than IN recorded as an output, UNKNOWN included only OUT/INOUT (scalars now too) an undeclared-intent array the routine writes has no reference; fail-closed in direction, but unstated and untested

Two more belong on the same ledger though they are not new on these branches:

# Where Behaviour Note
10 src/recast/transform/numpy/statements.py:142-184 (#27 adds the for...else rule) a DO index read after its loop gets Fortran's completion value when the analysis sees the read; the analysis is line-order based (a redefinition in one if-arm hides a read in the sibling arm, a read before the loop inside an enclosing loop's next iteration, an index that is a dummy the caller reads) and falls back to Python's last value without refusing main has always done the fallback; it is documented nowhere
11 src/recast/transform/numpy/constants.py (pre-#22) real and the kind inquiries have spelled np.float64 since before these PRs #22 widens what reaches that spelling; the assumption predates it

Found reviewing the tier-0/tier-1 CLUBB PRs; none of these is visible in the corpus numbers (31/67 mechanical, 433 deferred on main and on tier1-grid alike), which is the point: a silent fallback moves nothing a report can see.


Second sweep (2026-09-04, later): every open PR again for this pattern alone, plus #29 and #30 as merged

#29 holds its claim: its three continue paths became raises with tests. #22 and #25 gave nothing beyond rows 1-6. The rest:

# PR Where before after How a wrong result passes or goes unrecorded
12 #30 (on main) src/recast/verify/bitexact.py:892-905 any exception from the candidate was candidate raised: ..., the subprogram failed SystemExit or IndexError declines the draw: redrawn with a fresh seed, up to draws_per_trial = 24; the reference is never called on the declined draw; only when all 24 are declined is it an error a mistranslated guard (an ERROR STOP taken on inputs the Fortran accepts) or a data-dependent wrong subscript that fires on most valid draws passes once one draw survives; redrawn is written to metrics["subprograms"][name] only, with no threshold and no mention in the verdict detail. The reshaped * 2 > len(rounds) rule at :1053 covers IndexError alone
13 #30 (on main) src/recast/verify/bitexact.py:1002-1034 a NaN on both sides at the same position was ULP 0; every other element and every other output label of the trial was compared a both-sided NaN in any one output label discards the whole trial, every label and every non-NaN element already staged, and redraws mismatches in the other elements and the other labels of that trial are never counted; with _f_sqrt returning NaN on negative input and the default draw range, a bug correlated with the NaN-producing region (a branch on a negative argument) is never compared. Counted only as redrawn
14 #30 (on main) src/recast/fortran/interface.py:1452-1531 (_written_or_escaping, _infer_read_only_intents) an undeclared intent stayed UNKNOWN and bitexact refused the subprogram ("this verifier cannot...") inferred IN when the pass sees no write; the pass walks Part_Ref and Structure_Constructor only (:1490), not Function_Reference (which extract itself walks at :723), not ASSOCIATE aliases, and a Part_Ref whose base is not one of this file's subprograms is taken for a subscript (:1494) a dummy modified through a use-associated or external function, or an internal-unit WRITE, is spelled intent(in) in the wrapper, not returned by the candidate, and compared on neither side. test_fortran_analysis.py:1262 flipped UNKNOWN to IN; test_fortran_frontend.py:329 weakened == "UNKNOWN" to "no override key"
15 #27 (widens row 8) src/recast/fortran/flatten.py:1075-1098 with interface.py:1252-1264 every wanted module variable became a StateVar, an adapter argument the run's value is written into record["public"] is built from public :: statements only; integer, public, save :: nzmax = 10 in a default-private module is classed private and goes to left_to_module (verified with interface.extract) the variable is reachable by use and the recorded run may have set it; both sides run at the declared default. left_to_module lives only in facts.extra["flat_plans"]; no verdict, handle or ungated entry names it
16 #27 src/recast/fortran/flatten.py:961-966 a touched component whose dtype is not in FORTRAN_TYPES (character) reached flat._declare, which raised ValueError(... is not flat): no adapter, unit fails loudly dropped from the plan into left_to_module with "the physics reads it only to print"; the written flag is dropped with it nothing checks how the component is used: a select case (trim(err_info%err_header)) steering physics runs at the type default on both sides while the recorded outputs came from the run's value; surfaced in no verdict
17 #27 src/recast/transform/jax/tree.py:1709-1740 (_guard_after_returns, from _single_exit) an early return in a function with several outputs was NotFlat: kernel delegated, recorded when every early return is the final tuple and one sits in a loop, return becomes _ret = True and only the statements after the returning statement in each block are wrapped in if not _ret statements that precede the returning if inside the loop body are unguarded and run on every remaining iteration: do i; y(i,:) = y(i,:) + 1; if (err(i) /= 0) return; end do gives [3, 3] for err = [1, 0] where Fortran gives [3, 2]. The test's loop puts the if first, so it never exercises this
18 #27 src/recast/transform/numpy/subprograms.py:1033-1050 a constructor inside a larger initializer went to the parse, which raised NoRule and deferred the subprogram the text around each (/ ... /) goes through the token pass (strip_kind and case), never through Expressions the token pass has no integer division: integer, parameter :: h(3) = (/1, 2, 3/) / 2 renders np.array([1, 2, 3], dtype=np.int32) / 2, a float64 [0.5, 1.0, 1.5], where the parse path spells _f_int_div. Nothing refuses
19 #27 src/recast/transform/jax/backend.py:406-416, 761-764 (_static_test) if mod.f(2): went to the cond lowering; a body with no carried names raised JaxQueue("IF with no carried effects"), delegated and in the evidence any module.func(constants) test is "static" and lowered to a plain Python if; in the statics branch a JaxQueue from _cond_form is swallowed when the callee is a ported companion whose closure is traced state, bool(tracer) is a ConcretizationTypeError at trace time: a run failure outside delegated. Not silently wrong; the refusal moved from emission to trace
20 #27 src/recast/transform/jax/tree.py:906-918, 1197-1218 (host_calls) a companion procedure its port did not lower was NotFlat the anchor's host call is kept inside the kernel and named in notes.host_calls, assuming its guard is trace-time static when the guard lowers to lax.cond, both arms trace the host call; a host function that survives tracers runs once at trace and its value is baked into the jit cache. host_calls records the call, not whether its guard was static (plausible, not exercised)
21 #27 src/recast/fortran/frontend.py:668-676, rwset.py:252-288, verify/rwset.py:182 (no refusal before; a mis-record) when a stubbed module is absent from the tree, every name in its only: list, constants and types included, is recorded as a stub procedure a read of such a constant is dropped from the Fortran-side block reads and from the Python side alike, so the rwset gate cannot see a disagreement there, and stub_procedures in the evidence misnames constants as procedures (small)

Rows 12-14 are on main now (#30 merged 2026-09-04); the others follow #27. A claim the second sweep made and withdrew after checking: the numpy constants module reads entry.get("dtype") and use.py does set it, so a declared type is honoured there.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:emitterthe NumPy translation (statements, expressions, parameters)area:flattenflat adapters for derived-type interfaces (flatten, recorder, plans)area:frontendFortran parsing, interface extraction, constant foldingarea:verifierthe gates: static.rwset, differential.bitexact/tolerance, oraclesbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions