What each conformance rule's verdict actually depends on
Derived by forensic.rules.dependence; held by engine/tests/test_rule_dependence.py.
The question
The obvious objection to this tool has an answer: agreement with Primavera is
67.85% across the corpus and 78.06% over the 66 gate-passing projects, and
the causes are enumerated in LIMITATIONS.md. The follow-up is the hard one:
"Then why would I let it near a submission?"
Because most of what a reviewer runs never reaches the CPM engine. "Is a data date declared", "are there open ends", "is there a mandatory constraint", "is this relationship start-to-finish", "is that activity id eleven characters" are facts read out of the submitted file. They are as good as the importer's reading of the file — a separate question this document does not measure — and they do not inherit the agreement figure at all. A rule that quotes a computed date, a total float or longest-path membership does inherit it, exactly.
Nobody could act on that distinction until it was derived, so this partition is worked out of the rules' own code rather than written down by hand.
The three partitions
| Partition | The verdict is | Inherits 67.85%? |
|---|---|---|
| file | a fact about the submitted file. No path from the rule to Snapshot.result at all. |
No |
| engine-gated | a file fact — the declared scheduling options, the calendars, the data date — that the rule reads off the computed result, or that it abstains on when nothing was computed. | No, but it abstains if the schedule did not calculate |
| computed | a quotation of what the passes produced: early and late dates, total or free float, criticality, longest path, driving predecessor, project bounds, suppressed links. | Yes, in full |
One of those columns changed meaning on 2 September 2026, not just its
agreement rate. is_longest_path now reports membership of the driving
set — the backward closure over driving relationships, which branches at
every tie — rather than of a single chain, because that is what P6's
driving_path_flag means. A rule quoting longest-path membership is therefore
quoting a wider object than it was, and the ordered chain, which is what the
PASEG, NYSDOT, GAO and AACE 29R-03 continuity questions walk, is still
published separately under its own name. engine/oracle/corpus/SWEEP-2.md
carries the change and the prediction check.
A file row means three checks that fail in different ways all say the rule does not reach the engine — not that it provably cannot. What each of them can and cannot see, and the one occasion the answer was wrong, is under The technique; read it before quoting this table as a guarantee.
The partition, as derived
| pack | rules | file | engine-gated | computed |
|---|---|---|---|---|
ufgs |
48 | 35 | 3 | 10 |
asce67 |
35 | 32 | 0 | 3 |
aace29 |
37 | 29 | 1 | 7 |
scl |
35 | 35 | 0 | 0 |
aace_review |
61 | 34 | 15 | 12 |
vdot |
26 | 17 | 8 | 1 |
gao |
29 | 21 | 0 | 8 |
aace84 |
8 | 8 | 0 | 0 |
caltrans |
11 | 8 | 0 | 3 |
paseg |
5 | 2 | 0 | 3 |
nysdot |
16 | 11 | 0 | 5 |
navfac |
19 | 16 | 0 | 3 |
wisdot |
5 | 4 | 1 | 0 |
wvdoh |
16 | 13 | 2 | 1 |
| total | 351 | 265 | 30 | 56 |
The
aace29row moved on 5 September 2026, and the total row was found stale in the same pass.AACE-29R-03-4.2.Gwas reclassified from file to computed because it stopped taking §4.2.G test 1 on the delay chronology alone: the clause requires the parent delay to be equally or more critical than the paced activity, and §4.2.G says in terms that answering it means computing the two delays' total floats relative to each other, so the rule now readsNetworkResult.total_float_daysand the relationships. The reclassification is the honest consequence — its verdict really does rest on our backward pass now, and it did not before.quality/NEXT-CAPABILITY.md§3 has the defect it closed.The total row is not bound by
test_the_document_publishes_the_derived_counts, whose row pattern requires a backtick-quoted pack name and therefore skips**total**entirely. It was carrying 248 / 28 / 55 while the packs summed to 249 / 28 / 54 — stale by one in two columns, against a check that passed. The rule that moved happens to make the published total correct again, which is the worst way for a figure to become right, so the check was extended to bind the total row as well rather than leaving the coincidence in place.The
navfacrow moved again on 5 September 2026, and the share fell with it.engine/quality/DUTIES-OUTSIDE-THE-METHOD.mdadded four rules for the clauses of UFGS 01 32 17.00 20 outside its§1.10tree — the Baseline NAS recording no progress, its data date against the contract award date, the 60-calendar-day cap on summary construction activities, and the monthly update cadence. All four arefile, and they are file for the reason the paragraph below gives about the rest of that pack: a data date, an actual date, a percent complete and an original duration are all columns P6 wrote, and none of them is a number our passes produced. So the computed count is unmoved at 55 and the share falls from 16.6% to 16.4% — a denominator movement, not a change of position.
56 of 351 rules — 16.0% — rest on the CPM arithmetic. 265 do not touch it.
The share moved twice on 6 September 2026, both times because of wvdoh, and
the pack's two documents pull in opposite directions. Not one of its ten
specification rules is computed, because §108.3 states no metric in computed
terms — it names no critical-path definition, no float threshold and no share —
so the first ten rules moved the denominator alone and took the share from 16.4%
to 15.9%. Its six checklist rules then supplied the pack's only computed rule
and one of its two engine-gated ones, and the share came back to 16.0%. That
asymmetry is the useful finding here: a reviewer's own procedure asks for
things a specification's words do not, and WVDOH-E9 — checklist item E.9,
negative total float — is the pack's single rule whose verdict rests on our
backward pass rather than on a column P6 wrote. It says so in its own summary
text, which is the discipline this table exists to make checkable.
The share rose from 15.9% to 16.7% when nysdot landed on 2 September 2026 and
to 16.9% when navfac landed the same day, and fell to 16.6% when wisdot
landed on 2 September 2026 — none of the three movements is a change of
position. NYSDOT's review instrument is twenty-nine P6 filters, most of which
select on stored columns, and the five rules there that are computed are the five
its specification defines in computed terms — Longest Path, total float, and a
duration measured from the data date. navfac is the same story with a smaller
numerator: UFGS 01 32 17.00 20 §1.10.1.3 defines critical activities as those on
the Longest Path, so the two rules that read that definition are computed by the
specification's own wording rather than by our choice of metric, and §1.4.2.f's
out-of-sequence set is the third. wisdot moves the share down because none of
its five rules is computed at all: WisDOT SE Region's comment library states no
metric in computed terms — no critical-path definition, no float threshold, no
share — so there is nothing in it for the CPM arithmetic to decide. Its one
ENGINE_GATED rule is the only one outside ufgs, aace29, aace_review and
vdot, and it is the plainest example of the category: WI-MFB-PROJECT reads
SchedulerOptions.must_finish_by, a value P6 stored in the file, which reaches
a rule only through the object the scheduler built.
One number in this table moved for a reason that is not a new rule.
driving_set was added to COMPUTED_FIELDS when navfac landed: it is a
computed answer reachable only through a method, so the two rules reading it
classified ENGINE_GATED until it was named. No other pack calls it, so no other
row moved — but the row that would have been published for navfac without it
was 12/2/1, which is the PASEG defect exactly one method along.
The population is the library: every pack forensic.rules registers. No one
command runs all ~~326~~ 335 — a job's jurisdiction selects some of them, --review
elects aace_review, and --contract names vdot, gao, aace84, caltrans,
paseg, nysdot, navfac or wisdot. navfac is the first --contract pack that is
federal: UFGS 01 32 17.00 20 is a Division 01 section like UFGS 01 32 01.00
10, but a federal contract carries one of the two and not both, so putting it in
US_FEDERAL's default precedence would run two contradictory specifications
against one schedule. Every pack is reachable by some command
(engine/tests/test_unreached.py); until 2 September 2026 vdot was not, so the
26 rules in its row were counted here and ran for nobody.
paseg has the highest computed share of any pack — three of five — and the
consequence is not the one the ratio suggests. All three are the §10.1
credible-critical-path characteristics, which are the driving chain and nothing
else, so they inherit the agreement figure in full. But paseg emits no PASS and
no FAIL at all: PASEG contains no shall, so every finding in that pack is an
OBSERVATION or a NOT_EVALUATED. What inherits the agreement figure there is an
observation, not a verdict — the reader is being told where to look, on
arithmetic that is 67.85% agreed, rather than being told that something complies.
That is a weaker exposure than ufgs's ten computed rules, which do decide.
The partition does not carry that distinction, and this paragraph is the only
place it is recorded. Dependence.COMPUTED is a statement about what a rule
reads, not about what it can conclude, so a computed FAIL and a computed
OBSERVATION are one row here. They are not one risk: a FAIL that rests on our
float is an assertion a contractor can be held to, and an OBSERVATION that rests
on it is a place to look. Of the 55 computed rules, three — the paseg §10.1
set — are in a pack whose context class has no ok and no fail on it at all,
so they can never be more than a pointer; 25 returned a PASS or FAIL on the probe
submission described below. That 25 was measured over the 47 computed rules of
the ten packs that existed before nysdot, and the probe has not been re-run
against the five that pack adds nor the three navfac adds — of NYSDOT's,
three (NY-IR-G, NY-639-FLOAT, NY-BP-08b) can return a PASS or a FAIL and two
(NY-BP-08a, NY-IR-I) cannot; of NAVFAC's, NAVFAC-1.4.2.f can return a PASS or a
FAIL, NAVFAC-1.10.1.3 can only do so once the contract's bracketed threshold is
supplied, and NAVFAC-1.12 is a JUDGMENT rule that can never return either. The figure is left at 25 rather than adjusted by inspection, because the
sentence is about what the probe observed. Splitting the enum would put the same fact
in two places and invite them to disagree: every finding already carries both
Finding.verdict and Finding.dependence, and forensic.cli prints the pair
per finding, so the distinction is in the output without a fourth partition.
Where it is not yet in the output is the summary note above the findings:
cli._findings_section counts a computed finding into computed_ids whether
its verdict is PASS, FAIL or OBSERVATION, so the sentence a reviewer reads first
treats a pointer and an assertion alike. That is a cli.py question, not a partition
one, and it is recorded here rather than fixed here.
caltrans splits 8/0/3 and the three are the ones that must: the 50 percent
critical-or-near-critical cap and the negative-float prohibition are statements
about total float, and the start-milestone date is the date the passes put it on.
The other eight — both bands, predecessor-and-successor, negative lag, completed
work, the file name, the Department-owned float link, the baseline data date —
are facts about the file, which is the shape a band specification produces: a
count, a duration and a topology are all read rather than calculated.
gao and nysdot are the packs with no engine-gated rules at all — gao
was the only one until 2 September 2026 — and gao's shape is worth reading
rather than just counting. Eight of its 29 quote the passes, and they
are the eight the Guide predicates on a computed schedule: the critical-path
rules, the float distribution, negative float. The other 21 are facts about the
file — relationship types, dangling logic, summary logic, names, durations,
constraints, actual dates against the status date — which is why a GAO
assessment is mostly as good as the importer rather than as good as the
agreement figure.
The table covers every pack in forensic.rules because the pack list is
discovered, not written: dependence.packs() walks the package for modules
exposing a REGISTRY, and test_the_partition_covers_every_pack_in_the_package
compares that against the pinned list. It exists because this page said "five
packs, 216 rules" for as long as vdot had been registered, wired into
cli._AUTHORITY_PACKS and shipping: every assertion passed, because both sides
of every comparison read the same hand-written list of five.
The three ufgs engine-gated rules are UFGS-098 (§3.12.f, critical activities
defined as Longest Path), UFGS-099 (§3.12.g, progressed activities set to
Retained Logic) and UFGS-071 (§3.5.4.1, continuous flow, no circular logic). The
first two read a setting the file declares, off the result object that carries
it. The ten computed ones are UFGS-019, -031, -035, -036, -044, -052, -059,
-067, -085 and -091.
UFGS-071 arrived here on 2026-09-03 and is the one rule in the table that
reads result for neither a file fact nor a computed answer: it reads whether
there is one. A snapshot carrying a CPM result was necessarily acyclic
before the clause was asked -- the reader cannot schedule a network with a loop
-- so the rule abstains rather than publishing a PASS it could not have
withheld. The classifier is right to move it: its verdict does depend on the
engine having run. What it does not do is quote any number the engine computed,
so it stays out of computed and does not inherit the P6 agreement figure.
engine/quality/NEVER-ADVERSE.md section 4.1 has the measurement -- 66 corpus
PASSes withdrawn in each arm, no FAIL count moved.
The vdot pack's eight engine-gated rules are the largest such group after
aace_review: VDOT-13a through -13g are the seven Primavera P6 Schedule
Options settings the specification dictates, and VDOT-03b is Must Finish By.
All eight read a setting the submitted file declares, off the result object that
carries it — the same shape as UFGS-098/-099, seven times over, because VDOT
writes out in full what UFGS states in two clauses.
One vdot rule quotes a computed answer: VDOT-07 (II.7, no negative total
float in the Baseline). It was two until 2026-09-02, when the PASS audit
(engine/quality/PASS-AUDIT.md) removed the other. VDOT-14 (II.2.A(14)(b), the
Baseline Data Date against the earlier of the Contract execution date and the
first activity's planned start) took that planned start from
result.early_start — this engine's own forward pass, which floors every early
start at the data date, so the test reduced to comparing the data date with
itself. That is the scd_end_date leak CLAUDE.md records, and removing it
moved VDOT-14 from computed to file: what remains is a comparison between two
dates the file states. The count fell because a rule stopped reading our
arithmetic, not because the partition got looser. At 1 of 26 that is the lowest
non-zero computed share of any pack, and the reason is what the specification
is: VDOT Category III II.2.A is a conformance list — file format, activity
coding, naming, durations, and seven checkboxes in P6 — rather than a method of
analysis. A pack written against a submission-requirements clause lands almost
entirely in file and engine-gated; a pack written against an analysis method
(aace_review, 12 computed of 61) does not.
Adding vdot therefore moved the headline down, from 14.8% to 14.0%. The
level is not the reading: the arithmetic-dependent count rose by two, and the
share fell because the 26 rules that arrived were the kind that do not need the
engine. Neither number is a target, and a pack of pure float rules would move it
the other way without anything having got worse.
The SCL pack touches no computed field anywhere. That is not a virtue of the pack; the Delay and Disruption Protocol's core principles are about records, entitlement and method selection, and its rules read terms and activity data.
The UFGS number, which is the one a DOT or USACE reviewer runs
36 of 48 UFGS rules (75%) are independent of the CPM engine, and 38 of 48 (79%) do not quote a computed answer. Ten do.
Two narrower readings of "the ones they would actually run":
- By what can decide from the schedule alone. 19 UFGS rules are registered DETERMINISTIC; 15 are file, 2 engine-gated, 2 computed. The rest are HYBRID (they need a contract term) or JUDGMENT (they refer the question to a person and never return PASS).
- By what actually returned a verdict. Run the whole pack against a
well-formed two-snapshot submission with no contract terms supplied
(
engine/tests/test_rule_dependence.py::_watched_submission): 25 UFGS rules return PASS or FAIL, and 20 of those 25 — 80% — are file rules. Across all ten packs, 125 rules decide and 100 of them (80%) do not rest on the arithmetic: 87 file, 13 engine-gated, 25 computed.
The three numbers agree to within a few points, from three different angles.
That "all ten packs" was the whole library when this pair was last re-run,
and it was six before that. nysdot, navfac and wisdot landed afterwards and
the pair has not been re-run over thirteen — so read the 125 and the 100 as a
measurement over ten packs, which is what they are, and not as a count of the
current library. The reading is what carries forward; the digits belong to the
population they were taken on. The pair used to read 102 decide, 83 of them (81%) over the six
packs that existed when it was first taken; gao, aace84, caltrans and
paseg landed after it, and the previous agent marked the sentence as needing a
re-run rather than adjusting the digits by arithmetic. Re-run, the reading did
not change and the shape underneath it did. The four new packs added 23
deciding rules, and the split held at 80% because they arrived in both
directions: gao decides 19 with 14 of them file facts, and aace84 and
paseg decide nothing at all on this submission — 13 rules between them, and
not one PASS or FAIL. The level is stable; what moved is that a fifth of the
library now returns no verdict on a well-formed schedule, which is a different
fact about the packs than the ratio reports.
Since vdot landed — and since --contract vdot_progress_schedule made it
something a reviewer can actually run — the section title is no longer quite
right: a Virginia DOT reviewer runs the vdot pack, and its reading is
stronger still. 24 of 26 VDOT rules (92%) do not quote a computed answer,
and on the same bare two-snapshot submission 13 VDOT rules return PASS or FAIL,
12 of them file or engine-gated. Only scl reads higher on that second
measure, and it decides five rules to VDOT's thirteen.
The technique, and what it cannot see
forensic.rules.dependence.reached_fields disassembles the rule's own function
and follows every module-level function it calls, transitively, collecting the
attribute names reachable. Most packs register a wrapper closure
that reads ctx.result for every rule regardless of the body, so the wrapper is
stepped through and its declared needs read out of the same closure. The
computed field set is read off cpmcore.cpm.NetworkResult itself, so a new
field on the result is computed from the moment it exists, with nobody
remembering to add it.
Static inspection of Python is approximate. Every approximation here is deliberately biased toward calling a rule dependent, because over-claiming independence is the failure that would matter:
- An attribute name counts wherever it appears, on any object. A rule reading
task.early_start— the date the importer read, not one we computed — is called computed. - A function that calls
getattrcontributes all of its string constants, because the attribute name is an argument rather than an opcode. One rule (AACE-78R-13-PA-CRITICAL-DEFINITION) is classified computed partly on the wordlongest_pathappearing in a message string. It is computed for real reasons too; the point is that the heuristic errs one way. needs="result"orneeds="pair"on the registration counts as reaching the result even where the body never does.
What it therefore cannot see, and the first item is not hypothetical:
- A field reached through a method call.
c.path()captures the namepathand stops, because following the call would mean resolving whatcis and a wrong resolution is worse than none. On 2 September 2026 PASEG-10.1b and PASEG-10.1c reachedlongest_path()exactly that way and were published file — "this verdict does not rest on our arithmetic" — on two rules that rest on the driving chain and nothing else. The claim was the reverse of the truth, and it was the strongest claim this page can make. - An attribute reached through a name assembled at runtime from parts that are not literals in the same function.
- A helper handed in as a callback rather than named.
- A computed number routed in through
Submission.termsby a caller who calculated it somewhere else — which is outside any static method, because by then the number did not come from this engine.
How large is the method gap. It was sized before anything was built about it,
because the answer determines what is worth building. forensic and cpmcore
define 1,433 methods under 122 distinct names. Of those names, 14 have a body that can
reach a computed field — longest_path, is_critical, apply_to, summary,
to_dict, to_row, to_rows, to_json, to_list, rows_for,
standalone_rows_for, payload, recompute, verify. No file or
engine-gated rule mentions any of them. The dynamic check below agrees:
across all 295 rules as the library stood when it was run — three packs
smaller than the 335 the table above partitions — static and runtime
classification disagree on none.
The exposure was two rules, and they are fixed.
That is why there is no fourth partition. An UNKNOWN bucket for "the
derivation could not follow this" is the honest-abstention shape this repository
uses everywhere else, and it was rejected on the measurement rather than on
taste: all 248 of the rules currently published as independent make at
least one method call, because they call .strip(), .items() and ctx.skip(),
so a rule that abstains wherever a call cannot be followed would abstain on every
one of them and publish nothing at all. Narrowed until it stops guessing —
abstain only where the method name can reach a computed field — it applies to
zero rules, which makes it an assertion and not a partition. So it is one:
test_no_independent_rule_mentions_a_method_that_reaches_a_computed_field
fails the build if a file or engine-gated rule ever names one of the 14.
It reports a route to read, not a verdict; the method may genuinely be a
different one of that name, and the rule should then say so.
The scan has its own hole, and it is stated rather than papered over: a name
that is also a method of a builtin type is not followed, because append on one
of our classes and append on a list are the same token, and following both
drags the whole call graph into one blob — measured: including them takes the
name set from 14 to 29 and flags 60 rules, none of which reads a float. So a
method of ours named get, add or items that read a float would be missed by
the scan. The runtime check would still see it.
Neither check is a proof, and between them they do not make one. The static
pass cannot follow a method; the name scan cannot follow a name a builtin also
uses; the runtime check cannot see a branch the fixture does not reach. A file
verdict on this page means "three checks that fail in different ways all say
this rule does not touch the engine" — not "it provably cannot". The reason to
trust it is that the one time it was wrong, one of the three caught it within the
hour, and the others were built out of that failure.
The runtime check is the one that did it.
test_no_rule_reaches_a_computed_field_it_was_not_classified_for runs all 295
rules against a real submission whose NetworkResult is a recording proxy, and
fails if any rule the disassembler called independent touches a computed field at
runtime. On the current fixture 33 rules touch one, every one of them already
classified computed. It is a witness against over-claimed
independence, not a proof of coverage: a rule that abstains for want of a
contract term never reaches its computed branch, so the dynamic check does not
see that path.
The classification can be wrong, and was made to be
Five mutations, each reverted:
- A file rule made to quote a float.
getattr(c.result, "total_float_days")added to UFGS-101 (activity id length). Static classification moved it to computed; the pinned counts failed (ufgsfile 36 → 35, computed 10 → 11). - The same rule made to quote a float by a name static inspection cannot
read —
getattr(c.result, "total_float_" + _part)where_partis built from the snapshot label at runtime, defeating constant folding. The static pass demoted it only to engine-gated, and the dynamic check named it: "UFGS-101: touched ['total_float_days']". This is the case the tripwire exists for. - A computed rule made to look independent. UFGS-031 (§3.3.8 artificial
float constraints) rewritten to synthesise floats from the constraint date
instead of reading
res.total_float_days. It fell out of computed (10 → 9) and the counts failed. - A whole pack removed from the partition — the actual defect, run in
reverse.
vdotdeleted fromEXPECTEDand its row deleted from the table above:test_the_partition_covers_every_pack_in_the_packageandtest_the_document_publishes_the_derived_countsboth failed, namingvdot. Then the seventh-pack case, which is the one that has to hold tomorrow: an empty_mutant_pack.pydropped intoforensic/rules/with aREGISTRYand nothing else. The discovery found it, the pinned list did not have it, and the same two tests failed. Both reverted. - The PASEG defect itself, reconstructed — the mutation that sizes what the
method scan is worth.
_driving_pathwas recompiled as a method_Ctx.pathin the pack's own namespace and PASEG-10.1b's body rewritten to callc.path(), which is what both rules said an hour before this page was written.classifyreturned file — unchanged, and still wrong, which is the point: the static partition cannot see through a method and this mutation does not make it.computed_method_names()grewpath, andtest_no_independent_rule_mentions_a_method_that_reaches_a_computed_fieldfailed naming "PASEG-10.1b: ['path']". The check catches the defect that produced it, statically — the original was caught only at runtime, so this also catches the version of it that abstains on the fixture. Done by monkey-patching the loaded module in a scratch script rather than by editingpaseg.py, which is under a live audit; nothing in the tree changed.
A new rule joins a partition automatically — nothing declares its partition — and changes the pinned counts, which fails the build until someone re-reads this page. A new pack does the same, which is the half that was missing: the discovery and the pinned list are written by different hands, so neither can cover for the other. That is the intent: the numbers here are cheap, and the paragraphs around them are not.
The direct answer
Yes, and by a large margin, but not uniformly.
A reviewer running the UFGS pack against a submitted schedule gets 48 rules, of which 38 never quote a number this engine computed. Of the verdicts actually returned on a schedule with no contract terms in hand, 80% are file facts. Those verdicts are worth what the importer is worth, not what the agreement figure is worth.
The ten UFGS rules that are worth 67.85% are not incidental ones, and the honest form of the claim says so: UFGS-035 (End Project float sign), UFGS-036 (interim completion negative float), UFGS-031 (artificial float constraints), UFGS-044 (out-of-sequence progress) and UFGS-059 (early completion schedule) are float and date questions, and float is where our disagreement with P6 concentrates. A report presenting all 48 verdicts at one confidence would be overstating a fifth of them and understating three-quarters.
What this partition licenses is a report that says which is which — and what it forbids is quoting the pack's pass rate as though every clause in it were equally sound.
Source: docs/RULE-DEPENDENCE.md. Source commit date: 2026-09-06.