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 aace29 row moved on 5 September 2026, and the total row was found stale in the same pass. AACE-29R-03-4.2.G was 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 reads NetworkResult.total_float_days and 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 navfac row moved again on 5 September 2026, and the share fell with it. engine/quality/DUTIES-OUTSIDE-THE-METHOD.md added four rules for the clauses of UFGS 01 32 17.00 20 outside its §1.10 tree — 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 are file, 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 allgao 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":

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:

What it therefore cannot see, and the first item is not hypothetical:

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:

  1. 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 (ufgs file 36 → 35, computed 10 → 11).
  2. The same rule made to quote a float by a name static inspection cannot readgetattr(c.result, "total_float_" + _part) where _part is 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.
  3. 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.
  4. A whole pack removed from the partition — the actual defect, run in reverse. vdot deleted from EXPECTED and its row deleted from the table above: test_the_partition_covers_every_pack_in_the_package and test_the_document_publishes_the_derived_counts both failed, naming vdot. Then the seventh-pack case, which is the one that has to hold tomorrow: an empty _mutant_pack.py dropped into forensic/rules/ with a REGISTRY and nothing else. The discovery found it, the pinned list did not have it, and the same two tests failed. Both reverted.
  5. The PASEG defect itself, reconstructed — the mutation that sizes what the method scan is worth. _driving_path was recompiled as a method _Ctx.path in the pack's own namespace and PASEG-10.1b's body rewritten to call c.path(), which is what both rules said an hour before this page was written. classify returned 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() grew path, and test_no_independent_rule_mentions_a_method_that_reaches_a_computed_field failed 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 editing paseg.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.

See it in practice

Follow the evidence, from the schedule to the finding.

Explore the worked example