Skip to content

Commit a721422

Browse files
authored
Merge pull request #1 from markndg/radar-plot-visualiser
Added behavioural fingerprint and compatibility scoring
2 parents 29e7ca4 + c9737b5 commit a721422

11 files changed

Lines changed: 19696 additions & 15 deletions

File tree

README.md

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ until a customer complained.
1414

1515
Arsenic distinguishes **blocking behavioural regressions** (factual errors, schema breaks, instruction failures) from **presentation drift** (formatting, verbosity, markdown structure) and **telemetry drift** (latency, consistency). A model upgrade can look scary on raw dimension counts while still being safe to ship — the report tells you which is which.
1616

17+
Arsenic also generates a **behavioural fingerprint** for every comparison: an evidence-backed visual map of where the candidate retained baseline behaviour and where it changed. This is a compatibility fingerprint, not a global quality score or model personality profile.
18+
1719
---
1820

19-
<![Arsenic drift report — gpt-4o-mini vs gpt-4.1-mini showing 3 critical regressions](docs/report-screenshot.png)
21+
![Arsenic behavioural fingerprintbaseline retention radar from a real gpt-4o-mini gpt-4.1-mini report](docs/fingerprint-screenshot.png)
2022

2123
---
2224

@@ -47,6 +49,52 @@ Open the prebuilt reports in your browser — no install required:
4749
- [GPT-4.1-mini → GPT-5.4-mini (standard suite)](https://markndg.github.io/arsenic/examples/gpt-4_1-mini_vs_gpt-5_4-mini.html)
4850
- [GPT-4o-mini → GPT-4.1-mini](https://markndg.github.io/arsenic/examples/gpt-4o-mini_vs_gpt-4_1-mini.html)
4951
- [Llama 3.1:8b → Llama 3.2:3b](https://markndg.github.io/arsenic/examples/llama3_1-8b_vs_llama3_2-3b.html)
52+
- [Behavioural fingerprint demo](docs/fingerprint-demo.html) (re-rendered from the shipped fixture)
53+
54+
---
55+
56+
## Behavioural fingerprint
57+
58+
Every HTML/JSON report includes a **baseline-retention** fingerprint when enough dimensions were evaluated.
59+
60+
| Score | Meaning |
61+
|------:|---------|
62+
| **100** | No detected drift from the baseline in that dimension |
63+
| **0** | Every applicable probe showed drift |
64+
| Between | Share of applicable probes that stayed baseline-equivalent |
65+
66+
**Important:** a smaller polygon is not “a worse model”. Improvements count as drift (compatibility changed) but are labelled as improvements in the detail panel and table. Latency stays off the radar as telemetry; it appears as an adjacent summary card.
67+
68+
**Axes** (included only when Arsenic has applicable observations): Morphology, Tone, Factual, Schema, Instruction, Refusal, Consistency retention, Claim retention, Semantic retention. Omitted axes are listed outside the polygon with an explicit reason (no applicable probes, disabled, or unavailable).
69+
70+
**Consistency retention** uses multi-run mean pairwise embedding distance when `--consistency-runs` > 1. That “variance” is `mean(1 − cosine)` over L2-normalised non-negative hash-bag embeddings, so it is intrinsically in `[0, 1]`; clamping is a defensive float guard, not a rescale of unbounded statistical variance:
71+
72+
```
73+
repeatability% = 100 × (1 − clamp(variance, 0, 1))
74+
retention = 100 − |candidate_repeatability − baseline_repeatability|
75+
```
76+
77+
The axis measures similarity to baseline consistency (both increased and decreased consistency count as drift).
78+
79+
**Ordinary dimensions** use Arsenic’s existing materiality rules (same bands that drive Amber/Red in each dimension), not raw numeric inequality and not risk colour as a proxy for unchanged:
80+
81+
```
82+
unchanged = applicable && !materially_changed
83+
changed = applicable && materially_changed
84+
retention = 100 × unchanged / applicable
85+
```
86+
87+
Direction counts (regressions / improvements / neutral) apply only to materially changed probes. Risk/severity remains a separate deployment signal.
88+
89+
**Consistency retention** keeps an aggregate repeatability similarity score. Per-probe fingerprint drift counts only **band crossing** (`v1_consistent != v2_consistent`): both sides inconsistent with similar variance is absolute telemetry, not compatibility drift. Absolute inconsistency remains in the consistency dimension `probes_affected` count. Near-identical aggregate scores (retention ≥ 95%) appear under telemetry, not as high-impact compatibility drops.
90+
91+
Re-render old JSON (fingerprint is derived if missing):
92+
93+
```bash
94+
arsenic report render ./report.json --format html --output ./report.html
95+
```
96+
97+
Open [docs/fingerprint-demo.html](docs/fingerprint-demo.html) for a real-data example. The radar needs at least three valid axes; otherwise the retention table still renders.
5098

5199
---
52100

@@ -282,7 +330,7 @@ arsenic probe list List standard probes
282330
arsenic probe list --category tone Filter by category
283331
arsenic probe show <name> Show one probe as JSON
284332
arsenic probe validate <path> Validate user corpus TOML
285-
arsenic report render <json> Re-render a saved JSON report
333+
arsenic report render <json> Re-render a saved JSON report (derives fingerprint if absent)
286334
arsenic report summary <json> Print summary to stdout
287335
```
288336

@@ -291,17 +339,18 @@ arsenic report summary <json> Print summary to stdout
291339
## Built in Rust
292340

293341
Fast. No runtime dependencies. The report is a single self-contained HTML file with no
294-
external CDN calls after the font load.
342+
chart CDN the behavioural fingerprint radar is inline SVG (optional Google Fonts only).
295343

296344
```
297345
crates/
298-
arsenic-core/ Comparison engine, claim matching, mutation engine
346+
arsenic-core/ Comparison engine, claim matching, fingerprint, mutation engine
299347
arsenic-probes/ TOML probe loader
300348
arsenic-adapters/ OpenAI-compatible, Anthropic, Google adapters
301349
arsenic-report/ HTML / JSON report rendering
302350
arsenic-cli/ arsenic binary
303351
probe-suite/standard/ Standard probe suite (18 probes, 7 categories)
304352
examples/ Prebuilt HTML drift reports
353+
docs/ Fingerprint demo HTML/JSON + screenshot
305354
```
306355

307356
---

crates/arsenic-core/src/comparison.rs

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ impl ComparisonEngine {
149149
let upgrade_path = build_upgrade_path(&probe_results, &[]);
150150
let latency_summary = compute_latency_summary(&probe_results);
151151
let migration_profile = compute_migration_profile(&probe_results, &latency_summary);
152+
let behaviour_fingerprint =
153+
crate::fingerprint::compute_behaviour_fingerprint(&probe_results, &v1_model, &v2_model);
152154
Ok(DriftReport {
153155
run_id,
154156
generated_at: chrono::Utc::now(),
@@ -163,6 +165,7 @@ impl ComparisonEngine {
163165
mutation_results: Vec::new(),
164166
latency_summary,
165167
migration_profile,
168+
behaviour_fingerprint,
166169
})
167170
}
168171

@@ -808,7 +811,14 @@ impl ComparisonEngine {
808811
}
809812

810813
pub fn compute_dimension_summaries(&self, results: &[ProbeResult]) -> DimensionSummaries {
811-
DimensionSummaries {
814+
use crate::materiality::{
815+
claim_materially_changed, consistency_materially_changed, factual_materially_changed,
816+
instruction_materially_changed, morphology_materially_changed,
817+
refusal_materially_changed, schema_materially_changed, semantic_materially_changed,
818+
tone_materially_changed,
819+
};
820+
821+
let mut summaries = DimensionSummaries {
812822
morphology: dim_summary_core("morphology", results, |d| {
813823
Some((&d.morphology.risk, d.morphology.direction))
814824
}),
@@ -840,7 +850,68 @@ impl ComparisonEngine {
840850
custom_assertions: dim_summary_core("custom_assertions", results, |d| {
841851
d.custom_assertions.as_ref().map(|c| (&c.risk, c.direction))
842852
}),
843-
}
853+
};
854+
855+
// Material counts use the same helpers as the fingerprint (not risk colour).
856+
// Consistency probes_affected remains absolute inconsistency (risk ≠ Green).
857+
summaries.morphology.materially_changed_probes = results
858+
.iter()
859+
.filter(|pr| morphology_materially_changed(&pr.dimensions.morphology))
860+
.count();
861+
summaries.tone.materially_changed_probes = results
862+
.iter()
863+
.filter(|pr| tone_materially_changed(&pr.dimensions.tone))
864+
.count();
865+
summaries.factual.materially_changed_probes = results
866+
.iter()
867+
.filter(|pr| {
868+
pr.dimensions
869+
.factual
870+
.as_ref()
871+
.is_some_and(factual_materially_changed)
872+
})
873+
.count();
874+
summaries.schema.materially_changed_probes = results
875+
.iter()
876+
.filter(|pr| {
877+
pr.dimensions
878+
.schema
879+
.as_ref()
880+
.is_some_and(schema_materially_changed)
881+
})
882+
.count();
883+
summaries.instruction.materially_changed_probes = results
884+
.iter()
885+
.filter(|pr| {
886+
pr.dimensions
887+
.instruction
888+
.as_ref()
889+
.is_some_and(instruction_materially_changed)
890+
})
891+
.count();
892+
summaries.refusal.materially_changed_probes = results
893+
.iter()
894+
.filter(|pr| refusal_materially_changed(&pr.dimensions.refusal))
895+
.count();
896+
summaries.semantic.materially_changed_probes = results
897+
.iter()
898+
.filter(|pr| semantic_materially_changed(&pr.dimensions.semantic))
899+
.count();
900+
summaries.claim.materially_changed_probes = results
901+
.iter()
902+
.filter(|pr| claim_materially_changed(&pr.dimensions.claim, pr.probe.category))
903+
.count();
904+
summaries.consistency.materially_changed_probes = results
905+
.iter()
906+
.filter(|pr| {
907+
pr.dimensions
908+
.consistency
909+
.as_ref()
910+
.is_some_and(consistency_materially_changed)
911+
})
912+
.count();
913+
914+
summaries
844915
}
845916
}
846917

@@ -1481,6 +1552,11 @@ impl DriftReport {
14811552
self.migration_profile =
14821553
compute_migration_profile(&self.probe_results, &self.latency_summary);
14831554
self.dimension_summaries = engine.compute_dimension_summaries(&self.probe_results);
1555+
self.behaviour_fingerprint = crate::fingerprint::compute_behaviour_fingerprint(
1556+
&self.probe_results,
1557+
&self.v1_model,
1558+
&self.v2_model,
1559+
);
14841560
}
14851561

14861562
/// Recomputes valence counts from `probe_results` and copies probe-level buckets into `summary`.
@@ -1702,17 +1778,20 @@ fn build_upgrade_path(results: &[ProbeResult], mutations: &[MutationResult]) ->
17021778
.count(),
17031779
auto_certified: mutations.iter().filter(|m| m.validated).count(),
17041780
};
1705-
UpgradePathReport {
1781+
let mut path = UpgradePathReport {
17061782
critical_regressions: critical,
17071783
policy_changes: policy,
17081784
blocking_regressions: blocking,
1785+
changes_to_verify: verify.clone(),
17091786
improvements_to_verify: verify,
17101787
neutral_changes: neutral,
17111788
presentation_drift: presentation,
17121789
telemetry_drift: telemetry,
17131790
certified_prompts: certified,
17141791
remediation,
1715-
}
1792+
};
1793+
path.sync_review_aliases();
1794+
path
17161795
}
17171796

17181797
fn dim_summary_core<Rd>(dimension: &str, results: &[ProbeResult], mut get: Rd) -> DimensionSummary
@@ -1753,6 +1832,8 @@ where
17531832
drift_neutral,
17541833
drift_not_applicable,
17551834
impact_label,
1835+
// Ordinary dimensions: non-Green risk is the materiality signal.
1836+
materially_changed_probes: affected,
17561837
}
17571838
}
17581839

@@ -1765,7 +1846,12 @@ fn morphology_risk_level(
17651846
) -> RiskLevel {
17661847
if token_delta_pct >= red || (response_type_changed && token_delta_pct >= amber) {
17671848
RiskLevel::Red
1768-
} else if token_delta_pct >= amber || structure_changed || response_type_changed {
1849+
} else if crate::materiality::morphology_crosses_material_band(
1850+
token_delta_pct,
1851+
response_type_changed,
1852+
structure_changed,
1853+
amber,
1854+
) {
17691855
RiskLevel::Amber
17701856
} else {
17711857
RiskLevel::Green
@@ -1846,6 +1932,15 @@ fn soften_dimensions_for_code_formatting(probe: &Probe, dims: &mut ProbeDimensio
18461932
if fence_only_structure || dims.morphology.delta.token_delta_pct < 0.10 {
18471933
dims.morphology.risk = RiskLevel::Green;
18481934
dims.morphology.direction = DriftDirection::Neutral;
1935+
// Keep materiality helpers aligned with the softened Green decision.
1936+
dims.morphology.delta.structure_changed = false;
1937+
dims.morphology.delta.response_type_changed = false;
1938+
if dims.morphology.delta.token_delta_pct
1939+
>= crate::materiality::DEFAULT_MORPHOLOGY_TOKEN_DELTA_AMBER
1940+
{
1941+
dims.morphology.delta.token_delta_pct =
1942+
crate::materiality::DEFAULT_MORPHOLOGY_TOKEN_DELTA_AMBER - f64::EPSILON;
1943+
}
18491944
}
18501945

18511946
dims.semantic.risk = RiskLevel::Green;

0 commit comments

Comments
 (0)