diff --git a/crates/code-intel-cli/src/hospital_score.rs b/crates/code-intel-cli/src/hospital_score.rs new file mode 100644 index 00000000..ee818a2c --- /dev/null +++ b/crates/code-intel-cli/src/hospital_score.rs @@ -0,0 +1,258 @@ +//! Hospital report scoring, ported from `archive/run-code-intel.ps1`. +//! +//! The Rust hospital emitted `null` for every score while the PowerShell +//! launcher computed real values, so this was never duplication — it was the +//! only implementation, and it lived in the file T2 is retiring. See +//! docs/ps1-exit/t2-dot-source-parity-map.md §3. +//! +//! This module is the scoring arithmetic only. It is deliberately pure: no +//! I/O, no artifact reading, no knowledge of where the inputs come from. The +//! wiring — which run signals feed which score — is a separate problem, +//! because roughly half the launcher's score inputs (DSM scope, CodeNexus +//! context, runtime CI health) have no Rust producer yet. Emitting `0` for +//! those would be the same mistake the structural-scope fix corrected: `0` +//! here means *observed and absent*, not *never attempted*. +//! +//! Hence `allow(dead_code)`: the arithmetic is complete and verified against +//! the PowerShell original, but nothing calls it until those inputs exist. +//! Wiring it against inputs the pipeline does not produce would publish +//! confidently wrong scores, which is worse than the `null`s it replaces. +#![allow(dead_code)] + +/// `[math]::Round` in .NET is banker's rounding — half to even — and the +/// launcher relies on the default. `f64::round` rounds half away from zero, +/// so a direct translation silently disagrees on every exact `.5`, which is +/// reachable here (three- and four-term averages of integers). +fn round_half_to_even(value: f64) -> f64 { + let rounded = value.round(); + if (value - value.trunc()).abs() == 0.5 && rounded % 2.0 != 0.0 { + rounded - value.signum() + } else { + rounded + } +} + +fn round_to_int(value: f64) -> i64 { + round_half_to_even(value) as i64 +} + +/// `Get-StepScore`: a step is worth everything or nothing. Absent steps and +/// any non-`passed` status both score zero, matching the launcher's `switch` +/// with its `default` arm. +pub(crate) fn step_score(status: Option<&str>) -> i64 { + match status { + Some("passed") => 100, + _ => 0, + } +} + +/// `Get-ImportResolutionScore`: banded, and the bottom band is 30 rather than +/// 0 — an unresolved-heavy graph is degraded evidence, not absent evidence. +/// Only a genuinely unknown ratio scores zero. +pub(crate) fn import_resolution_score(resolved_ratio: Option) -> i64 { + let Some(ratio) = resolved_ratio else { + return 0; + }; + if ratio >= 75.0 { + 100 + } else if ratio >= 50.0 { + 75 + } else if ratio >= 25.0 { + 50 + } else { + 30 + } +} + +/// `New-HospitalMeasurements`' ratio: `None` when nothing was measured, so the +/// caller can tell "no imports seen" from "no imports resolved". Rounded to +/// one decimal, half to even, as the launcher does. +pub(crate) fn resolved_ratio(resolved_imports: i64, unresolved_imports: i64) -> Option { + let total = resolved_imports + unresolved_imports; + if total <= 0 { + return None; + } + let ratio = (resolved_imports as f64 * 100.0) / total as f64; + Some(round_half_to_even(ratio * 10.0) / 10.0) +} + +/// `Get-SourceCoverageScore`: what fraction of the inventory the structural +/// scan actually reached, capped at 100. Either side being non-positive means +/// the comparison is meaningless, not that coverage is bad. +pub(crate) fn source_coverage_score(scan_files: i64, inventory_files: i64) -> i64 { + if scan_files <= 0 || inventory_files <= 0 { + return 0; + } + round_to_int((scan_files as f64 * 100.0 / inventory_files as f64).min(100.0)) +} + +/// `New-HospitalScoreBlock`'s pollution arm. Absent evidence is `unknown` and +/// scores zero; present evidence scores 100 when something was quarantined and +/// 80 when nothing needed to be, because "nothing excluded" is a weaker +/// signal than "exclusions were computed and applied". +pub(crate) fn pollution_score(pollution_status: &str, excluded_files: i64) -> i64 { + if pollution_status == "unknown" { + 0 + } else if excluded_files > 0 { + 100 + } else { + 80 + } +} + +/// `New-HospitalMeasurements`' pollution classification. +pub(crate) fn pollution_status(has_evidence: bool, excluded_files: i64) -> &'static str { + if !has_evidence { + "unknown" + } else if excluded_files > 0 { + "quarantined" + } else { + "clean" + } +} + +/// Rules present is worth 100, absent 45 — governance without rules is +/// degraded rather than absent, the same shape as the import bands. +pub(crate) fn rules_score(rules_exist: bool) -> i64 { + if rules_exist { + 100 + } else { + 45 + } +} + +/// The three composite scores the hospital report exposes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct CompositeScores { + pub(crate) governance: i64, + pub(crate) diagnostic: i64, + pub(crate) overall: i64, +} + +/// The composition `New-HospitalScoreBlock` performs: governance over three +/// terms, diagnostic over four, and overall over the two composites plus the +/// two standalone dimensions. Averaging composites into the overall is the +/// launcher's shape and is preserved deliberately — changing the weighting +/// would move every historical score. +pub(crate) fn compose( + rules: i64, + gate: i64, + check: i64, + ct: i64, + mri: i64, + graph: i64, + memory: i64, + resolution: i64, + pollution: i64, +) -> CompositeScores { + let governance = round_to_int((rules + gate + check) as f64 / 3.0); + let diagnostic = round_to_int((ct + mri + graph + memory) as f64 / 4.0); + let overall = round_to_int((diagnostic + governance + resolution + pollution) as f64 / 4.0); + CompositeScores { + governance, + diagnostic, + overall, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn step_score_is_all_or_nothing_and_absent_is_nothing() { + assert_eq!(step_score(Some("passed")), 100); + assert_eq!(step_score(Some("failed")), 0); + assert_eq!(step_score(Some("skipped")), 0); + assert_eq!(step_score(Some("manual_required")), 0); + assert_eq!(step_score(None), 0); + } + + #[test] + fn import_resolution_bands_match_the_launcher_including_the_band_edges() { + assert_eq!(import_resolution_score(Some(100.0)), 100); + assert_eq!(import_resolution_score(Some(75.0)), 100); + assert_eq!(import_resolution_score(Some(74.9)), 75); + assert_eq!(import_resolution_score(Some(50.0)), 75); + assert_eq!(import_resolution_score(Some(49.9)), 50); + assert_eq!(import_resolution_score(Some(25.0)), 50); + assert_eq!(import_resolution_score(Some(24.9)), 30); + assert_eq!(import_resolution_score(Some(0.0)), 30); + // Unknown is the only zero: a fully unresolved graph still scores 30. + assert_eq!(import_resolution_score(None), 0); + } + + #[test] + fn resolved_ratio_is_none_when_nothing_was_measured() { + assert_eq!(resolved_ratio(0, 0), None); + assert_eq!(resolved_ratio(0, 5), Some(0.0)); + assert_eq!(resolved_ratio(1055, 0), Some(100.0)); + assert_eq!(resolved_ratio(1, 2), Some(33.3)); + assert_eq!(resolved_ratio(2, 1), Some(66.7)); + } + + #[test] + fn source_coverage_is_capped_and_meaningless_comparisons_score_zero() { + assert_eq!(source_coverage_score(232, 232), 100); + // A scan wider than the inventory caps rather than exceeding 100. + assert_eq!(source_coverage_score(500, 232), 100); + assert_eq!(source_coverage_score(116, 232), 50); + assert_eq!(source_coverage_score(0, 232), 0); + assert_eq!(source_coverage_score(232, 0), 0); + assert_eq!(source_coverage_score(-1, 232), 0); + } + + #[test] + fn pollution_distinguishes_unknown_from_clean() { + assert_eq!(pollution_status(false, 0), "unknown"); + assert_eq!(pollution_status(true, 0), "clean"); + assert_eq!(pollution_status(true, 3), "quarantined"); + assert_eq!(pollution_score("unknown", 0), 0); + assert_eq!(pollution_score("clean", 0), 80); + assert_eq!(pollution_score("quarantined", 3), 100); + } + + #[test] + fn rules_absent_is_degraded_not_absent() { + assert_eq!(rules_score(true), 100); + assert_eq!(rules_score(false), 45); + } + + /// The parity trap: `[math]::Round` is half-to-even, `f64::round` is half + /// away from zero. Both averages below land exactly on `.5`, so a naive + /// translation would disagree with every score the launcher ever emitted. + #[test] + fn composition_uses_bankers_rounding_like_the_launcher() { + // (100 + 100 + 45) / 3 = 81.666 -> 82; four-term 0.5 cases below. + assert_eq!(round_half_to_even(2.5), 2.0); + assert_eq!(round_half_to_even(3.5), 4.0); + assert_eq!(round_half_to_even(-2.5), -2.0); + assert_eq!(round_half_to_even(0.5), 0.0); + assert_eq!(round_half_to_even(1.5), 2.0); + // (100 + 0 + 100 + 0) / 4 = 50 exactly, no rounding involved. + assert_eq!(compose(100, 0, 100, 100, 0, 100, 0, 100, 80).diagnostic, 50); + // (0 + 0 + 0 + 1) / 4 = 0.25 -> 0; (0 + 0 + 1 + 2) / 4 = 0.75 -> 1. + assert_eq!(compose(0, 0, 1, 0, 0, 0, 1, 0, 0).governance, 0); + } + + /// A worked example against the shape the launcher produced on this + /// repository: rules present, gate and check passed, graph and memory + /// absent, no CT/MRI producers, full import resolution, clean pollution. + #[test] + fn a_governed_repository_without_optional_modalities_scores_predictably() { + let scores = compose( + rules_score(true), + step_score(Some("passed")), + step_score(Some("passed")), + 0, + 0, + step_score(None), + step_score(None), + import_resolution_score(resolved_ratio(1055, 0)), + pollution_score(pollution_status(true, 0), 0), + ); + assert_eq!(scores.governance, 100); + assert_eq!(scores.diagnostic, 0); + assert_eq!(scores.overall, 70); + } +} diff --git a/crates/code-intel-cli/src/main.rs b/crates/code-intel-cli/src/main.rs index 4288344c..597d6302 100644 --- a/crates/code-intel-cli/src/main.rs +++ b/crates/code-intel-cli/src/main.rs @@ -31,6 +31,7 @@ mod execution_policy; mod file_boundary; mod graph; mod hardened_git; +mod hospital_score; mod method_catalog; mod model_channels; mod orchestration; diff --git a/docs/ps1-exit/t2-dot-source-parity-map.md b/docs/ps1-exit/t2-dot-source-parity-map.md new file mode 100644 index 00000000..104d8cc1 --- /dev/null +++ b/docs/ps1-exit/t2-dot-source-parity-map.md @@ -0,0 +1,142 @@ +# T2 step 4: dot-source parity map (issue #47) + +The evidence the classification document said was required before any +PowerShell deletion: + +> "a Rust suite exists" is not by itself evidence that a specific assertion +> survives. Treat any PowerShell case with no Rust counterpart as a gap to +> close before the file is deleted, not after. +> +> — [t2-launcher-classification.md](t2-launcher-classification.md) §0 + +That verification is now done, and **it overturns the working assumption for +about half the surface.** Measured against `5065482`. + +## Result + +| dot-sourced group | functions | Rust counterpart | verdict | +|---|---|---|---| +| code-evidence symbol extraction | 9 | `native_code_evidence.rs` | genuinely duplicated — retire | +| sentrux gate metric deltas | 3 | `sentrux_gate.rs` | genuinely duplicated — retire | +| hospital state machine / diagnosis | ~5 | `hospital_diagnosis.rs` | genuinely duplicated — retire | +| **hospital scoring / measurements** | **~7** | arithmetic ported to `hospital_score.rs`; not wired, inputs incomplete | **integration gap, see §3** | + +## 1. Code-evidence symbol extraction — duplicated + +`New-CodeEvidenceNativeSymbol`, `Get-CodeEvidencePowerShellSymbol`, +`Get-CodeEvidencePythonSymbol`, `Get-CodeEvidenceJavaScriptSymbol`, +`Get-CodeEvidenceRustSymbol`, `Get-CodeEvidenceGoSymbol`, +`Get-CodeEvidenceJavaSymbol`, `Get-CodeEvidenceSymbolCandidate`, +`Get-CodeEvidenceSymbols`. + +Evidence the Rust owner covers the same ground: + +- `native_code_evidence.rs` handles every extension the PowerShell set does + and more: `ps1, psm1, py, rs, go, ts, tsx, js, jsx, mjs, cjs, java, cs`. +- It runs in production as the `evidence.native-code` DAG node; the live + self-scan of this repository emits 2993 symbols through it. +- `tests/native_code_evidence.rs` carries an explicit legacy-parity case, + `a01_a09_artifacts_match_the_real_legacy_producer_on_the_same_fixture`, + plus cases for unsupported languages, binary files and non-UTF-8 source + that the PowerShell suite does not have. + +Retiring these loses nothing and removes a second implementation of symbol +extraction that nobody runs. + +## 2. Sentrux gate metric deltas — duplicated + +`New-SentruxMetricDelta`, `Test-SentruxGateNoDegradation`, +`Resolve-SentruxMetricRegressions`. + +`sentrux_gate.rs` (1080 lines) computes the same four gated metrics and is +exercised on every CI run and every local `code-intel sentrux gate .` — this +campaign has been gated by it repeatedly, including two god-file regressions +it caught. Production use is stronger evidence than a unit test here. + +## 3. Hospital scoring — was NOT duplicated, and this is the finding + +`Get-StepScore`, `New-HospitalMeasurements`, `Get-ImportResolutionScore`, +`Get-SourceCoverageScore`, `New-HospitalScoreBlock`, `Read-HospitalArtifacts`, +`Read-HospitalArtifactFile`. + +**Status after this change:** the arithmetic is now ported to +`crates/code-intel-cli/src/hospital_score.rs` and verified value-for-value +against the PowerShell originals (dot-sourced and executed, not read). What +remains is *integration*, not translation — the launcher is not wired to it, +and several score inputs still have no Rust producer (§3.2). Until that lands, +the emitted report is unchanged and still carries nulls. + +The measurement that made this a finding rather than a de-duplication, taken +before the port on the same repository at the same commit: + +| field | PowerShell producer | Rust producer | +|---|---|---| +| `triage.overall_score` | `46` | `null` | +| `report_quality.overall_score` | `46` | `null` | +| `report_quality.diagnostic_score` | `50` | `null` | +| `report_quality.governance_score` | `33` | `null` | +| `report_quality.dimensions` | populated (e.g. `source_coverage`) | `[]` | + +`hospital_diagnosis.rs:410,425` hardcodes those nulls. It is not a TODO and +not a failure path — the fields are emitted with the legacy names and no +values, which `docs/hospital-diagnosis.md` describes as preserving "the legacy +stable fields used by existing readers". + +Nine of the sixteen PowerShell hospital test cases exist to pin this scoring +behaviour: + +- missing surgery target is unresolved +- missing current hotspot is unresolved +- known changed hotspot retains resolved behavior +- unknown import, pollution, and source coverage are explicit zero-confidence evidence +- known complete measurements retain pass scores +- known negative and partial measurements retain evidence-driven scores +- manual and skipped steps are unknown and receive zero confidence +- known modality artifacts retain established scores and explicit status +- deleted and corrupt modality artifacts remain zero-confidence + +Deleting them would not be de-duplication. It would delete the only test +coverage of behaviour that exists in exactly one place. + +The remaining seven cases — gate failure, unknown check, clean-gate green, +discharge evidence, missing structural summaries, provider quota, structural +completeness wiring — map onto +`precedence_matrix_matches_the_legacy_stable_diagnoses_and_fails_closed`, +`provider_quota_precedes_missing_current_graph_and_is_replay_stable`, and the +`structural_evidence_*` unit tests added in `d029b3f`. `?` on +"discharge requires affirmative resolved post-op target evidence": the Rust +ladder ends at `post_op` and no test names `discharge_ready`, so this one may +be a second, smaller gap. It needs its own check before deletion. + +### 3.2 What still blocks wiring + +The arithmetic is complete; roughly half its inputs are not produced by any +Rust node: + +| score dimension | input | Rust producer | +|---|---|---| +| governance (rules / gate / check) | structural signals | present | +| graph | architecture graph admission | present | +| source coverage | inventory file count, scan file count | present (`inventory.rg`, sentrux payload) | +| import resolution | resolved / unresolved import counts | present (sentrux payload) | +| pollution | DSM `scope.excluded_files` | **absent** — DSM is a PowerShell tool operation, T5 (#50) | +| MRI | CodeNexus context | **absent** | +| PET | runtime CI health, or what-if + evolution | **absent** | +| memory | repowise step result | **absent from the default DAG** | + +Wiring the arithmetic to inputs that do not exist would publish confidently +wrong numbers. `0` in this scheme means *observed and absent*, not *never +attempted* — the same distinction `d029b3f` drew for structural evidence, and +the reason `hospital_score.rs` ships unwired behind `allow(dead_code)`. + +## 4. Product decision (settled) + +Whether hospital scoring survives was not answerable from the code, so it was +escalated rather than guessed. **Decision: it survives** — the scoring is real +product behaviour, so it ports to Rust and the nine PowerShell test cases port +with it, rather than the null fields becoming the intended contract. + +Consequence for T2's exit criteria: `run-code-intel.ps1` cannot reach ≤50 +lines until the wiring in §3.2 lands, because the scoring engine has to stay +somewhere until Rust can produce the same numbers. The twelve genuinely +duplicated functions in §1 and §2 are unblocked and can retire first.