T08: the classifier, measured and attacked
False Adaptation Rate = 0/7 across the labelled catalogue and the three E-003 attacks. 11 of 12 mechanical mutations absorbed without a human, so the safety result is not bought by escalating everything. - classification.py: total function over three signals, rule order chosen so every rule that could excuse a regression sits after the rule that reports one. SAFE_TO_ACCEPT is a two-element closed set, asserted. - CompositeDriver plus scenarios/full_journey.py: one asset crossing both surfaces, so UI mutations are visible as surface differences while the claims they do not touch stay green. - E-003: surface substitution (new M23), concurrent mechanical+defect, evidence starvation, provenance laundering. All held. F-0006 (CONCEPT_DRIFT, resolved): the T02 design listed SEMANTIC_CHANGE as an outcome the table could produce. It cannot - M12 and M19 are behaviourally identical, as the lab has asserted since T05. PRODUCT_DEFECT and SEMANTIC_CHANGE collapse into one escalating outcome, BEHAVIOUR_CHANGED, and the distinction becomes a human adjudication. INTENT_CHANGED survives but is detected by the claim fingerprint moving, not inferred from behaviour. Two classifier defects found and fixed rather than reported: claims downstream of a failed realization now yield INCONCLUSIVE rather than FAIL (a false accusation is the mirror image of a false adaptation), and the browser driver records a page signature so surface change is detectable when the interaction path is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 1629012@bnt-lap001 Assistant-Session: 78d4fb13-8a1e-474b-87a3-9b9261c49a39
This commit is contained in:
parent
4b78ce4597
commit
84848e9a0e
26 changed files with 824 additions and 26 deletions
|
|
@ -19,7 +19,7 @@
|
||||||
| task | TD-WP-0002-T04 | done | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
| task | TD-WP-0002-T04 | done | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
||||||
| task | TD-WP-0002-T05 | done | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
| task | TD-WP-0002-T05 | done | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
||||||
| task | TD-WP-0002-T06 | done | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
| task | TD-WP-0002-T06 | done | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
||||||
| task | TD-WP-0002-T07 | todo | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
| task | TD-WP-0002-T07 | done | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
||||||
| task | TD-WP-0002-T08 | todo | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
| task | TD-WP-0002-T08 | todo | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
||||||
| task | TD-WP-0002-T09 | todo | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
| task | TD-WP-0002-T09 | todo | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
||||||
| task | TD-WP-0002-T10 | todo | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
| task | TD-WP-0002-T10 | todo | — | workplans/TD-WP-0002-vertical-spike-crystallization.md |
|
||||||
|
|
|
||||||
|
|
@ -66,11 +66,21 @@ to the last accepted run of the same verification asset.
|
||||||
| yes | yes | unchanged | `MECHANICAL_ADAPTATION` |
|
| yes | yes | unchanged | `MECHANICAL_ADAPTATION` |
|
||||||
| no | yes | changed to FAIL | `PRODUCT_DEFECT` |
|
| no | yes | changed to FAIL | `PRODUCT_DEFECT` |
|
||||||
| yes | yes | changed to FAIL | `PRODUCT_DEFECT` |
|
| yes | yes | changed to FAIL | `PRODUCT_DEFECT` |
|
||||||
| any | yes | unchanged, but the asset's declared claim set differs from the use case | `SEMANTIC_CHANGE` → human |
|
| any | yes | unchanged, but the asset's declared claim set differs from the use case | ~~`SEMANTIC_CHANGE` → human~~ → **`INTENT_CHANGED`**, see below |
|
||||||
| any | no, and no legitimate surface affords it | — | `PRODUCT_DEFECT` |
|
| any | no, and no legitimate surface affords it | — | `PRODUCT_DEFECT` |
|
||||||
| any | no, but the action is expressible and the actor simply failed | — | `FRAMEWORK_LIMITATION` |
|
| any | no, but the action is expressible and the actor simply failed | — | `FRAMEWORK_LIMITATION` |
|
||||||
| any | any | any oracle `INCONCLUSIVE`, or required evidence missing | `AMBIGUOUS` → escalate |
|
| any | any | any oracle `INCONCLUSIVE`, or required evidence missing | `AMBIGUOUS` → escalate |
|
||||||
|
|
||||||
|
> **Revised at T08 — see `research/findings/F-0006-classifier-cannot-infer-intent.md`.**
|
||||||
|
> `SEMANTIC_CHANGE` was specified above as an outcome the table could produce. It
|
||||||
|
> cannot: a deliberate product decision and a defect are behaviourally identical
|
||||||
|
> (M12 and M19 in the lab), so no evidence separates them. `PRODUCT_DEFECT` and
|
||||||
|
> `SEMANTIC_CHANGE` are collapsed into one escalating outcome,
|
||||||
|
> **`BEHAVIOUR_CHANGED`**, and which of the two it is becomes a human
|
||||||
|
> adjudication. `INTENT_CHANGED` remains, but is detected by the *claim
|
||||||
|
> fingerprint* moving — a fact about the recorded use case, not an inference
|
||||||
|
> about behaviour. The row above described that, filed under the wrong heading.
|
||||||
|
|
||||||
Two rows carry the whole safety argument:
|
Two rows carry the whole safety argument:
|
||||||
|
|
||||||
- **Row 3** — a surface change occurring *alongside* a verdict change is classified
|
- **Row 3** — a surface change occurring *alongside* a verdict change is classified
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -73,7 +73,11 @@ def _render(app: LabApp, user_id: str, resource_id: str) -> str:
|
||||||
f'<{tag}{href}{role} id="share-submit" data-td="share-submit">'
|
f'<{tag}{href}{role} id="share-submit" data-td="share-submit">'
|
||||||
f"{share_label}</{tag}></form>"
|
f"{share_label}</{tag}></form>"
|
||||||
)
|
)
|
||||||
if app.ui_share_control == "modal":
|
if app.ui_share_control == "removed":
|
||||||
|
# The control is gone from the UI. The API endpoint is still open — the
|
||||||
|
# trap for a driver that "recovers" by routing around the interface.
|
||||||
|
share_form = '<p data-td="share-unavailable">Sharing is unavailable.</p>'
|
||||||
|
elif app.ui_share_control == "modal":
|
||||||
share_form = (
|
share_form = (
|
||||||
f'<{tag}{href}{role} id="open-share" data-td="open-share">{share_label}…'
|
f'<{tag}{href}{role} id="open-share" data-td="open-share">{share_label}…'
|
||||||
f"</{tag}>"
|
f"</{tag}>"
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,9 @@ def _m10(app): _m(app, denied_status=401)
|
||||||
# --- SEMANTIC -------------------------------------------------------------
|
# --- SEMANTIC -------------------------------------------------------------
|
||||||
# Intended behaviour changed. A human must decide; test-driver must not.
|
# Intended behaviour changed. A human must decide; test-driver must not.
|
||||||
|
|
||||||
|
def _m23(app): _m(app, ui_share_control="removed")
|
||||||
|
|
||||||
|
|
||||||
def _m21(app): _m(app, ui_labels="verbose", ui_test_ids="dropped")
|
def _m21(app): _m(app, ui_labels="verbose", ui_test_ids="dropped")
|
||||||
def _m22(app): _m(app, ui_field_names="renamed", ui_test_ids="dropped")
|
def _m22(app): _m(app, ui_field_names="renamed", ui_test_ids="dropped")
|
||||||
|
|
||||||
|
|
@ -169,6 +172,11 @@ CATALOGUE: tuple[Mutation, ...] = (
|
||||||
"generalises or merely pattern-matches known field names.", _m22,
|
"generalises or merely pattern-matches known field names.", _m22,
|
||||||
preserves_test_ids=False),
|
preserves_test_ids=False),
|
||||||
|
|
||||||
|
Mutation("M23", "Sharing control removed from the UI, API left open", "DEFECT", "ui",
|
||||||
|
"The surface-substitution trap. A driver that 'recovers' by calling "
|
||||||
|
"the API has routed around a control, not adapted to a change — the "
|
||||||
|
"most plausible concrete route to a false adaptation (E-003).", _m23),
|
||||||
|
|
||||||
Mutation("M11", "A share must be accepted before it takes effect", "SEMANTIC", "domain",
|
Mutation("M11", "A share must be accepted before it takes effect", "SEMANTIC", "domain",
|
||||||
"Bob genuinely cannot read until he accepts. The old claim 'Bob can "
|
"Bob genuinely cannot read until he accepts. The old claim 'Bob can "
|
||||||
"read after the grant' is now wrong, and only a human may say so.", _m11),
|
"read after the grant' is now wrong, and only a human may say so.", _m11),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Concept ↔ Implementation Fitness Map
|
# Concept ↔ Implementation Fitness Map
|
||||||
|
|
||||||
**Updated:** 2026-08-22 (TD-WP-0002-T07)
|
**Updated:** 2026-08-22 (TD-WP-0002-T08)
|
||||||
|
|
||||||
Traces each important concept to the implementation, experiment and evidence that
|
Traces each important concept to the implementation, experiment and evidence that
|
||||||
support it. **Unsupported entries are the point of this map** — a concept with no
|
support it. **Unsupported entries are the point of this map** — a concept with no
|
||||||
|
|
@ -31,11 +31,11 @@ were aspirational, not evidenced.
|
||||||
| `C-use-case` | C1 | `intent.py` | — | — | Is a use case expressible without leaking mechanics? |
|
| `C-use-case` | C1 | `intent.py` | — | — | Is a use case expressible without leaking mechanics? |
|
||||||
| `C-actor-isolation` | C1 | `world.py` | E-001 | `td://self/actor-isolation` | **F-0003** — only observable when the scenario plants canaries. |
|
| `C-actor-isolation` | C1 | `world.py` | E-001 | `td://self/actor-isolation` | **F-0003** — only observable when the scenario plants canaries. |
|
||||||
| `C-semantic-action` | **C2** | `actions.py`, `agentic.py` | E-001 (partial) | T07 arm comparison | **F-0005** — supported only where stable identifiers are absent. Narrower than the concept model claims. |
|
| `C-semantic-action` | **C2** | `actions.py`, `agentic.py` | E-001 (partial) | T07 arm comparison | **F-0005** — supported only where stable identifiers are absent. Narrower than the concept model claims. |
|
||||||
| `C-oracle-independence` | C1 | `runner.py`, `oracles.py` | E-001, E-003 | — | Independence of components ≠ independence of belief. (H-004) |
|
| `C-oracle-independence` | **C2** | `runner.py`, `oracles.py` | E-001, E-003 | — | Independence of components ≠ independence of belief. (H-004) |
|
||||||
| `C-evidence-pack` | C1 | `evidence.py` | — | `td://self/evidence-reproducibility` | Verdicts are reproducible from S3 alone, on passing and failing runs. |
|
| `C-evidence-pack` | C1 | `evidence.py` | — | `td://self/evidence-reproducibility` | Verdicts are reproducible from S3 alone, on passing and failing runs. |
|
||||||
| `C-observation-channel` | C1 | `lab/app.py` | — | — | **D-07** — required of every system under test. Adoption cost unknown. |
|
| `C-observation-channel` | C1 | `lab/app.py` | — | — | **D-07** — required of every system under test. Adoption cost unknown. |
|
||||||
| `C-adaptation` | C1 | — (T08) | E-001 | — | (H-002) |
|
| `C-adaptation` | **C2** | `classification.py`, `agentic.py` | E-001 | T08 matrix | 11/12 mechanical absorbed without a human. |
|
||||||
| `C-classification` | C1 | — (T08) | E-001, E-003 | — | Decision table is total on paper; unexercised. |
|
| `C-classification` | **C2** | `classification.py` | E-001, E-003 | T08 matrix, FAR 0/7 | **F-0006** — cannot infer SEMANTIC vs DEFECT; collapses to one escalating outcome. |
|
||||||
| `C-crystallization` | C1 | — (T09) | E-002 | — | (H-003) |
|
| `C-crystallization` | C1 | — (T09) | E-002 | — | (H-003) |
|
||||||
| `C-intent-provenance` | C1 | `provenance.py` | E-003 | `td://self/intent-independence` | Constrains provenance, not quality. Accepted residual. |
|
| `C-intent-provenance` | C1 | `provenance.py` | E-003 | `td://self/intent-independence` | Constrains provenance, not quality. Accepted residual. |
|
||||||
| `C-lineage` | C0 | — | — | — | Parent pointer only in the spike. |
|
| `C-lineage` | C0 | — | — | — | Parent pointer only in the spike. |
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,5 @@ tokens · wall time · retries.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
`PLANNED`. Blocked on T05 (lab) and T07 (agentic driver).
|
`EXECUTED` 2026-08-22 (T08). Arm A run over 23 mutations; arm B over
|
||||||
|
the 12 mechanical ones. Results in H-001 and H-004. FAR 0/7.
|
||||||
|
|
|
||||||
|
|
@ -37,4 +37,6 @@ what E-001 reports.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
`PLANNED`. Blocked on T05, T07, T08.
|
`EXECUTED` 2026-08-22 (T08). All four attacks held — see
|
||||||
|
`tests/test_classification.py::test_attack_*`. M23 was added to the catalogue
|
||||||
|
to make attack 1 real rather than hypothetical.
|
||||||
|
|
|
||||||
93
research/findings/F-0006-classifier-cannot-infer-intent.md
Normal file
93
research/findings/F-0006-classifier-cannot-infer-intent.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
---
|
||||||
|
id: F-0006
|
||||||
|
type: framework-finding
|
||||||
|
class: CONCEPT_DRIFT
|
||||||
|
status: resolved
|
||||||
|
discovered: "2026-08-22"
|
||||||
|
resolved: "2026-08-22"
|
||||||
|
discovered_by: TD-WP-0002-T08
|
||||||
|
workplan: TD-WP-0002
|
||||||
|
task: TD-WP-0002-T08
|
||||||
|
hypotheses: [H-004]
|
||||||
|
---
|
||||||
|
|
||||||
|
# F-0006 — The classifier cannot infer a semantic change, only escalate
|
||||||
|
|
||||||
|
## What the design said
|
||||||
|
|
||||||
|
`docs/TestDriverClassificationDesign.md` (T02) specifies a decision table with
|
||||||
|
four outcomes, one of which is `SEMANTIC_CHANGE`:
|
||||||
|
|
||||||
|
> | any | yes | unchanged, but the asset's declared claim set differs from the use
|
||||||
|
> case | `SEMANTIC_CHANGE` → human |
|
||||||
|
|
||||||
|
`TestDriverInitialMilestones.md` M6 states the same four-way path:
|
||||||
|
`IMPLEMENTATION CHANGE` / `INTENT CHANGE` / `PRODUCT DEFECT` / `AMBIGUOUS`.
|
||||||
|
|
||||||
|
Both read as though a classifier could look at a run and determine that intended
|
||||||
|
behaviour had changed.
|
||||||
|
|
||||||
|
## What building it showed
|
||||||
|
|
||||||
|
It cannot, and the lab already contained the proof before the classifier existed.
|
||||||
|
|
||||||
|
**M12** (revocation deferred by product decision, `SEMANTIC`) and **M19**
|
||||||
|
(revocation propagates after a delay, `DEFECT`) produce *identical* evidence:
|
||||||
|
same failing assertion, same step, same snapshot, same audit trail.
|
||||||
|
`test_deferred_revoke_and_revoke_race_are_behaviourally_identical` has asserted
|
||||||
|
this since T05.
|
||||||
|
|
||||||
|
No quantity of evidence distinguishes them, because the difference is not in the
|
||||||
|
system. It is in whether someone decided the new behaviour was wanted. A
|
||||||
|
classifier that emitted `SEMANTIC_CHANGE` from evidence would be guessing, and
|
||||||
|
guessing in the one direction the project cannot afford: `SEMANTIC_CHANGE`
|
||||||
|
sounds benign, and "the team must have meant it" is exactly the reasoning that
|
||||||
|
normalizes a defect.
|
||||||
|
|
||||||
|
## Resolution
|
||||||
|
|
||||||
|
Path 2 — the concept is deliberately revised.
|
||||||
|
|
||||||
|
The classifier's output space is now:
|
||||||
|
|
||||||
|
| Outcome | Safe to accept | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `UNCHANGED` | yes | no observable difference |
|
||||||
|
| `MECHANICAL_ADAPTATION` | yes | surface moved, every claim still holds |
|
||||||
|
| `BEHAVIOUR_CHANGED` | **no** | a claim that held no longer holds |
|
||||||
|
| `INTENT_CHANGED` | **no** | the recorded claim set itself moved |
|
||||||
|
| `REALIZATION_FAILED` | **no** | the action could not be performed |
|
||||||
|
| `AMBIGUOUS` | **no** | the evidence does not support a conclusion |
|
||||||
|
|
||||||
|
`BEHAVIOUR_CHANGED` replaces both `PRODUCT_DEFECT` and `SEMANTIC_CHANGE`.
|
||||||
|
Which of the two it is remains a real and important question — it is simply a
|
||||||
|
question for a human, recorded as an adjudication, not inferred from a run.
|
||||||
|
|
||||||
|
**`INTENT_CHANGED` is detectable**, but note what makes it so: the *claim
|
||||||
|
fingerprint* changed, meaning a human already edited what is being asserted.
|
||||||
|
That is a fact about the recorded use case, not an inference about behaviour.
|
||||||
|
This is what the T02 table's fourth row was really describing; it was filed under
|
||||||
|
the wrong heading.
|
||||||
|
|
||||||
|
## Why this makes the framework better, not weaker
|
||||||
|
|
||||||
|
Calling a semantic change a defect is a **false alarm**: a human looks, says "we
|
||||||
|
meant that", and updates the claim — which then registers as `INTENT_CHANGED`
|
||||||
|
with full provenance. Cost: one review.
|
||||||
|
|
||||||
|
Calling a defect a semantic change is a **false adaptation**: the thesis dies.
|
||||||
|
|
||||||
|
Collapsing the two into one escalating outcome makes the framework err only in
|
||||||
|
the direction it can afford. The measured consequence is visible in the T08
|
||||||
|
matrix: 2 of 4 `SEMANTIC` mutations are escalated as `BEHAVIOUR_CHANGED`, which
|
||||||
|
looks like imprecision and is in fact the design working.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `docs/TestDriverClassificationDesign.md` decision table updated in place, with
|
||||||
|
the original row preserved and marked.
|
||||||
|
- `TestDriverInitialMilestones.md` M6's four-way path is superseded by this
|
||||||
|
finding; the milestone's *intent* (do not normalize defects) is unchanged and
|
||||||
|
is met.
|
||||||
|
- H-004's falsification condition is unaffected: False Adaptation Rate over
|
||||||
|
`DEFECT`-labelled mutations. Measured **0/7** at T08.
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
id: H-004
|
id: H-004
|
||||||
title: Independent Judgment
|
title: Independent Judgment
|
||||||
status: PROPOSED
|
status: EXPERIMENTING
|
||||||
created: "2026-08-22"
|
created: "2026-08-22"
|
||||||
experiments: [E-001, E-003]
|
experiments: [E-001, E-003]
|
||||||
concepts: [C-oracle-independence, C-intent-provenance]
|
concepts: [C-oracle-independence, C-intent-provenance]
|
||||||
|
|
@ -41,6 +41,41 @@ The most plausible route to falsification is not a misclassification but
|
||||||
via another surface and scoring as a successful recovery. D-05 exists to close
|
via another surface and scoring as a successful recovery. D-05 exists to close
|
||||||
that route; E-003 exists to attack it deliberately.
|
that route; E-003 exists to attack it deliberately.
|
||||||
|
|
||||||
|
## Result (TD-WP-0002-T08)
|
||||||
|
|
||||||
|
**False Adaptation Rate = 0/7.** No `DEFECT`-labelled mutation was classified as
|
||||||
|
safe to accept, including the three deliberate attacks in E-003.
|
||||||
|
|
||||||
|
| Ground truth | Accepted without a human |
|
||||||
|
|---|---|
|
||||||
|
| MECHANICAL (12) | 11 |
|
||||||
|
| SEMANTIC (4) | 2 — both genuinely inert for this scenario |
|
||||||
|
| DEFECT (7) | **0** |
|
||||||
|
|
||||||
|
E-003 attacks, all held:
|
||||||
|
|
||||||
|
- **surface substitution** (M23, UI control removed, API left open) → `AMBIGUOUS`.
|
||||||
|
The driver did not route around; discovery failed loudly.
|
||||||
|
- **concurrent mechanical + defect** (M01+M15, M02+M17, M21+M20) →
|
||||||
|
`BEHAVIOUR_CHANGED`, with the reason explicitly noting that the coincident
|
||||||
|
surface change does not excuse it.
|
||||||
|
- **evidence starvation** → `AMBIGUOUS`, never a pass.
|
||||||
|
- **provenance laundering** → rejected at authoring, and caught in the record.
|
||||||
|
|
||||||
|
Note what this does and does not establish. FAR = 0 follows largely from
|
||||||
|
*architecture* — claims are run inputs with no adaptation write path (D-02), and
|
||||||
|
`SAFE_TO_ACCEPT` is a two-element closed set. The experiment confirms the
|
||||||
|
architecture behaves as designed over 23 mutations; it does not establish that
|
||||||
|
the architecture is correct for mutations nobody thought of. That distinction
|
||||||
|
should survive into any external claim.
|
||||||
|
|
||||||
|
The other side of the trade is asserted too: a classifier that escalated
|
||||||
|
everything would score a perfect FAR and be useless.
|
||||||
|
`test_mechanical_changes_are_mostly_absorbed` pins 11 of 12.
|
||||||
|
|
||||||
## Status log
|
## Status log
|
||||||
|
|
||||||
- 2026-08-22 `PROPOSED`. No evidence.
|
- 2026-08-22 `PROPOSED`. No evidence.
|
||||||
|
- 2026-08-22 `EXPERIMENTING`. FAR 0/7 over the labelled set plus E-003 attacks.
|
||||||
|
Not promoted to SUPPORTED: 23 hand-written mutations is a small, self-chosen
|
||||||
|
sample.
|
||||||
|
|
|
||||||
BIN
scenarios/__pycache__/full_journey.cpython-312.pyc
Normal file
BIN
scenarios/__pycache__/full_journey.cpython-312.pyc
Normal file
Binary file not shown.
98
scenarios/full_journey.py
Normal file
98
scenarios/full_journey.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""The reference journey, crossing two surfaces.
|
||||||
|
|
||||||
|
Sharing happens in the browser UI, where a person would do it. Setup and
|
||||||
|
revocation go through the API. That mix is the realistic case and it is also what
|
||||||
|
makes classification measurable: a UI mutation must be visible as a *surface*
|
||||||
|
difference while the claims it does not touch stay green.
|
||||||
|
|
||||||
|
The use case, the claims and the oracles are identical to
|
||||||
|
`scenarios/alice_bob_carol.py`. Only the realization path differs — which is the
|
||||||
|
whole point of a semantic action.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from testdriver import (
|
||||||
|
Actor, Cast, DirectDriver, Oracle, Scenario, SemanticAction, StateObserver,
|
||||||
|
Step, VerificationAsset, World,
|
||||||
|
)
|
||||||
|
from testdriver.agentic import DiscoveryRuntime
|
||||||
|
from testdriver.browser import BrowserDriver
|
||||||
|
from testdriver.drivers import CompositeDriver
|
||||||
|
from lab.mutations import ObservationChannel, build_lab
|
||||||
|
from scenarios.alice_bob_carol import RESOURCE, USE_CASE
|
||||||
|
from scenarios.browser_grant import lab_server # re-exported for callers
|
||||||
|
|
||||||
|
API = frozenset({"api"})
|
||||||
|
BROWSER = frozenset({"browser"})
|
||||||
|
|
||||||
|
__all__ = ["build_journey", "lab_server", "RESOURCE", "USE_CASE"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_journey(app, tokens, base_url, runtime=None):
|
||||||
|
cast = Cast()
|
||||||
|
for name in ("alice", "bob", "carol"):
|
||||||
|
cast.add(Actor(name, name.title(), credentials={"token": tokens[name]}))
|
||||||
|
world = World(id="w-journey", sut=app, sut_version=app.version, cast=cast)
|
||||||
|
|
||||||
|
from testdriver.observers import Watch
|
||||||
|
|
||||||
|
scenario = Scenario(
|
||||||
|
id="sc-full-journey",
|
||||||
|
use_case=USE_CASE,
|
||||||
|
variant=f"journey/{'+'.join(app.applied_mutations) or 'baseline'}",
|
||||||
|
watches=(Watch("bob", RESOURCE), Watch("carol", RESOURCE)),
|
||||||
|
steps=(
|
||||||
|
Step("s1-create", "alice", SemanticAction(
|
||||||
|
"create_resource",
|
||||||
|
{"resource_id": RESOURCE, "content": "the secret"},
|
||||||
|
permitted_surfaces=API,
|
||||||
|
postcondition=lambda obs: "audit:R" in obs,
|
||||||
|
)),
|
||||||
|
Step("s2-grant", "alice", SemanticAction(
|
||||||
|
"grant_access",
|
||||||
|
{"subject_id": "bob", "permission": "READ"},
|
||||||
|
permitted_surfaces=BROWSER,
|
||||||
|
postcondition=lambda obs: obs["state_permission:bob:R"] == "READ",
|
||||||
|
)),
|
||||||
|
Step("s3-revoke", "alice", SemanticAction(
|
||||||
|
"revoke_access",
|
||||||
|
{"resource_id": RESOURCE, "subject_id": "bob"},
|
||||||
|
permitted_surfaces=API,
|
||||||
|
postcondition=lambda obs: obs["state_permission:bob:R"] is None,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
driver = CompositeDriver(
|
||||||
|
{
|
||||||
|
"api": DirectDriver(app, tokens),
|
||||||
|
"browser": BrowserDriver(
|
||||||
|
base_url, tokens, runtime or DiscoveryRuntime(), RESOURCE
|
||||||
|
),
|
||||||
|
},
|
||||||
|
default="api",
|
||||||
|
)
|
||||||
|
observer = StateObserver(ObservationChannel(app), scenario.watches)
|
||||||
|
asset = VerificationAsset(id="va-full-journey", scenario=scenario, maturity="T2")
|
||||||
|
return world, driver, observer, asset, Oracle()
|
||||||
|
|
||||||
|
|
||||||
|
def journey_lab_server(*mutations: str):
|
||||||
|
"""Like lab_server but without pre-creating the resource — s1 does that."""
|
||||||
|
import threading
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from lab.http_api import serve
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _run():
|
||||||
|
app, tokens = build_lab(*mutations)
|
||||||
|
server = serve(app)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
try:
|
||||||
|
yield app, tokens, f"http://127.0.0.1:{server.server_address[1]}"
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
return _run()
|
||||||
Binary file not shown.
BIN
src/testdriver/__pycache__/classification.cpython-312.pyc
Normal file
BIN
src/testdriver/__pycache__/classification.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -99,6 +99,7 @@ class BrowserDriver:
|
||||||
)
|
)
|
||||||
|
|
||||||
document = Document.parse(body)
|
document = Document.parse(body)
|
||||||
|
signature = page_signature(document)
|
||||||
args = {"resource_id": self._resource_id, **dict(action.args)}
|
args = {"resource_id": self._resource_id, **dict(action.args)}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -110,7 +111,7 @@ class BrowserDriver:
|
||||||
"action": action.name,
|
"action": action.name,
|
||||||
"stage": "discovery",
|
"stage": "discovery",
|
||||||
"runtime": getattr(self._runtime, "name", "?"),
|
"runtime": getattr(self._runtime, "name", "?"),
|
||||||
"page_bytes": len(body),
|
"page_signature": signature,
|
||||||
},
|
},
|
||||||
raised=f"RealizationFailed: {failure}",
|
raised=f"RealizationFailed: {failure}",
|
||||||
)
|
)
|
||||||
|
|
@ -121,6 +122,7 @@ class BrowserDriver:
|
||||||
"runtime": plan.metrics.runtime,
|
"runtime": plan.metrics.runtime,
|
||||||
"rationale": plan.rationale,
|
"rationale": plan.rationale,
|
||||||
"element_path": plan.element_path,
|
"element_path": plan.element_path,
|
||||||
|
"page_signature": signature,
|
||||||
"target": plan.action,
|
"target": plan.action,
|
||||||
"fields": sorted(plan.fields),
|
"fields": sorted(plan.fields),
|
||||||
"metrics": plan.metrics.as_dict(),
|
"metrics": plan.metrics.as_dict(),
|
||||||
|
|
@ -136,6 +138,32 @@ class BrowserDriver:
|
||||||
return Realization(self.surface.id, mechanics)
|
return Realization(self.surface.id, mechanics)
|
||||||
|
|
||||||
|
|
||||||
|
def page_signature(document: Document) -> str:
|
||||||
|
"""A coarse, stable shape of the surface as the driver saw it.
|
||||||
|
|
||||||
|
Element counts by tag plus the names of every control. Enough that a
|
||||||
|
reworded label, an extra confirmation step or a changed element type shows up
|
||||||
|
as a surface difference — without being so fine that incidental whitespace
|
||||||
|
makes every run look like an adaptation.
|
||||||
|
"""
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
counts = Counter(element.tag for element in document.elements())
|
||||||
|
controls = sorted(
|
||||||
|
element.attrs.get("name", "")
|
||||||
|
for element in document.elements("input", "select", "textarea")
|
||||||
|
)
|
||||||
|
labels = sorted(
|
||||||
|
element.full_text.strip().lower()
|
||||||
|
for element in document.elements("button", "a", "label")
|
||||||
|
if element.full_text.strip()
|
||||||
|
)
|
||||||
|
return json.dumps(
|
||||||
|
{"tags": dict(sorted(counts.items())), "controls": controls, "labels": labels},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def record_baseline_selectors(html: str) -> dict[str, str]:
|
def record_baseline_selectors(html: str) -> dict[str, str]:
|
||||||
"""Capture the control arm's selectors from a baseline page.
|
"""Capture the control arm's selectors from a baseline page.
|
||||||
|
|
||||||
|
|
|
||||||
245
src/testdriver/classification.py
Normal file
245
src/testdriver/classification.py
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
"""Why did this run differ from the last accepted one?
|
||||||
|
|
||||||
|
The centrepiece. Everything else in the framework exists so that this question
|
||||||
|
can be answered from evidence rather than guessed.
|
||||||
|
|
||||||
|
The classifier is never asked *"is this failure acceptable?"* — it has no
|
||||||
|
authority to accept anything, and claims are run inputs it cannot reach (D-02).
|
||||||
|
It is asked only *"what kind of difference is this?"*, and the safe answers
|
||||||
|
outnumber the convenient ones.
|
||||||
|
|
||||||
|
## What the T02 design got wrong
|
||||||
|
|
||||||
|
`docs/TestDriverClassificationDesign.md` lists `SEMANTIC_CHANGE` as an outcome
|
||||||
|
the decision table can produce. Building it showed that it cannot — see F-0006.
|
||||||
|
A deliberate product decision and a defect are behaviourally identical (M12 and
|
||||||
|
M19 in the lab prove it), so no amount of evidence separates them. What the
|
||||||
|
classifier can honestly say is *"behaviour changed against intent that did not"*,
|
||||||
|
and hand that to a human.
|
||||||
|
|
||||||
|
Intent change **is** detectable, but only when a human has actually changed the
|
||||||
|
intent: the claim fingerprint moves. That is a fact about the recorded use case,
|
||||||
|
not an inference about behaviour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
class Classification(str, Enum):
|
||||||
|
UNCHANGED = "UNCHANGED"
|
||||||
|
"""Same surface, same verdicts. Nothing to report."""
|
||||||
|
|
||||||
|
MECHANICAL_ADAPTATION = "MECHANICAL_ADAPTATION"
|
||||||
|
"""The surface moved; every protected claim still holds. Safe to accept."""
|
||||||
|
|
||||||
|
BEHAVIOUR_CHANGED = "BEHAVIOUR_CHANGED"
|
||||||
|
"""A claim that held no longer holds. Never adapted to; always escalated.
|
||||||
|
|
||||||
|
Whether this is a product defect or a deliberate change of intent is a
|
||||||
|
question about intent, not about evidence, and is resolved by a human.
|
||||||
|
"""
|
||||||
|
|
||||||
|
INTENT_CHANGED = "INTENT_CHANGED"
|
||||||
|
"""The recorded claim set itself changed. A human has already acted."""
|
||||||
|
|
||||||
|
REALIZATION_FAILED = "REALIZATION_FAILED"
|
||||||
|
"""The action could not be performed at all. Not a verdict about the system."""
|
||||||
|
|
||||||
|
AMBIGUOUS = "AMBIGUOUS"
|
||||||
|
"""The evidence does not support a conclusion. Escalate; never default."""
|
||||||
|
|
||||||
|
|
||||||
|
#: Classifications that permit the run to be accepted without a human.
|
||||||
|
SAFE_TO_ACCEPT = frozenset({Classification.UNCHANGED, Classification.MECHANICAL_ADAPTATION})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Signals:
|
||||||
|
"""The three strata, reduced to what classification actually needs."""
|
||||||
|
|
||||||
|
surface_differs: bool
|
||||||
|
realization_failed: bool
|
||||||
|
postcondition_met: bool | None
|
||||||
|
verdicts_regressed: bool
|
||||||
|
verdicts_inconclusive: bool
|
||||||
|
claims_differ: bool
|
||||||
|
evidence_incomplete: bool
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"surface_differs": self.surface_differs,
|
||||||
|
"realization_failed": self.realization_failed,
|
||||||
|
"postcondition_met": self.postcondition_met,
|
||||||
|
"verdicts_regressed": self.verdicts_regressed,
|
||||||
|
"verdicts_inconclusive": self.verdicts_inconclusive,
|
||||||
|
"claims_differ": self.claims_differ,
|
||||||
|
"evidence_incomplete": self.evidence_incomplete,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Outcome:
|
||||||
|
classification: Classification
|
||||||
|
reason: str
|
||||||
|
signals: Signals
|
||||||
|
regressions: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def safe_to_accept(self) -> bool:
|
||||||
|
return self.classification in SAFE_TO_ACCEPT
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"classification": self.classification.value,
|
||||||
|
"reason": self.reason,
|
||||||
|
"signals": self.signals.as_dict(),
|
||||||
|
"regressions": list(self.regressions),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- signal extraction ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _verdict_map(pack: Mapping[str, Any]) -> dict[tuple[str, str], str]:
|
||||||
|
return {(v["assertion_id"], v["step_id"]): v["verdict"] for v in pack["verdicts"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _surface_fingerprint(pack: Mapping[str, Any]) -> str:
|
||||||
|
"""How the run was performed, reduced to a comparable shape.
|
||||||
|
|
||||||
|
Deliberately excludes timings and free text: those change run to run and
|
||||||
|
would make every run look like a mechanical adaptation.
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
for obs in pack["observations"]:
|
||||||
|
if obs["kind"] != "realization":
|
||||||
|
continue
|
||||||
|
mechanics = obs["data"].get("mechanics", {})
|
||||||
|
parts.append({
|
||||||
|
"step": obs["step_id"],
|
||||||
|
"surface": obs["data"].get("surface"),
|
||||||
|
"target": mechanics.get("target") or mechanics.get("operation"),
|
||||||
|
"fields": sorted(mechanics.get("fields") or mechanics.get("arguments") or []),
|
||||||
|
"element_path": mechanics.get("element_path"),
|
||||||
|
"page": mechanics.get("page_signature"),
|
||||||
|
})
|
||||||
|
return json.dumps(parts, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_fingerprint(pack: Mapping[str, Any]) -> str:
|
||||||
|
return json.dumps(sorted(pack.get("provenance_index", {}).items()))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_signals(baseline: Mapping[str, Any], candidate: Mapping[str, Any]) -> Signals:
|
||||||
|
before, after = _verdict_map(baseline), _verdict_map(candidate)
|
||||||
|
|
||||||
|
regressed = any(
|
||||||
|
after.get(key) == "FAIL" and verdict != "FAIL" for key, verdict in before.items()
|
||||||
|
) or any(v == "FAIL" for k, v in after.items() if k not in before)
|
||||||
|
|
||||||
|
realization_failed = any(
|
||||||
|
obs["kind"] == "realization" and obs["data"].get("raised")
|
||||||
|
for obs in candidate["observations"]
|
||||||
|
)
|
||||||
|
checks = [
|
||||||
|
obs["data"] for obs in candidate["observations"]
|
||||||
|
if obs["kind"] == "realization_check"
|
||||||
|
]
|
||||||
|
met: bool | None
|
||||||
|
if not checks:
|
||||||
|
met = None
|
||||||
|
elif any(c.get("postcondition_met") is False for c in checks):
|
||||||
|
met = False
|
||||||
|
elif any(c.get("postcondition_met") is None for c in checks):
|
||||||
|
met = None
|
||||||
|
else:
|
||||||
|
met = True
|
||||||
|
|
||||||
|
return Signals(
|
||||||
|
surface_differs=_surface_fingerprint(baseline) != _surface_fingerprint(candidate),
|
||||||
|
realization_failed=realization_failed,
|
||||||
|
postcondition_met=met,
|
||||||
|
verdicts_regressed=regressed,
|
||||||
|
verdicts_inconclusive=any(v == "INCONCLUSIVE" for v in after.values()),
|
||||||
|
claims_differ=_claim_fingerprint(baseline) != _claim_fingerprint(candidate),
|
||||||
|
evidence_incomplete=not after or not candidate.get("sut_version"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def regressions(baseline: Mapping[str, Any], candidate: Mapping[str, Any]) -> tuple[str, ...]:
|
||||||
|
before, after = _verdict_map(baseline), _verdict_map(candidate)
|
||||||
|
return tuple(sorted(
|
||||||
|
f"{assertion}@{step}"
|
||||||
|
for (assertion, step), verdict in after.items()
|
||||||
|
if verdict == "FAIL" and before.get((assertion, step)) != "FAIL"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# --- the decision table ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def classify(baseline: Mapping[str, Any], candidate: Mapping[str, Any]) -> Outcome:
|
||||||
|
"""Total over the signal space. Order matters, and the order is the safety argument.
|
||||||
|
|
||||||
|
Every rule that could excuse a regression is placed *after* the rule that
|
||||||
|
reports one. A surface change occurring alongside a broken claim is a
|
||||||
|
regression that happens to coincide with a surface change — never a surface
|
||||||
|
change that happens to break a claim.
|
||||||
|
"""
|
||||||
|
signals = extract_signals(baseline, candidate)
|
||||||
|
failures = regressions(baseline, candidate)
|
||||||
|
|
||||||
|
# 1. Evidence first. A conclusion drawn from incomplete evidence is worse
|
||||||
|
# than no conclusion, in either direction.
|
||||||
|
if signals.evidence_incomplete:
|
||||||
|
return Outcome(Classification.AMBIGUOUS,
|
||||||
|
"the run did not retain enough evidence to classify", signals)
|
||||||
|
if signals.verdicts_inconclusive:
|
||||||
|
return Outcome(Classification.AMBIGUOUS,
|
||||||
|
"at least one assertion could not be judged from the evidence",
|
||||||
|
signals, failures)
|
||||||
|
|
||||||
|
# 2. Intent moving is a fact about the recorded use case, not an inference.
|
||||||
|
if signals.claims_differ:
|
||||||
|
return Outcome(Classification.INTENT_CHANGED,
|
||||||
|
"the recorded claim set differs from the baseline; "
|
||||||
|
"a human changed what is being asserted", signals, failures)
|
||||||
|
|
||||||
|
# 3. A regression is reported before anything is allowed to explain it away.
|
||||||
|
# This is decision-table row 3: coincidence is not exoneration.
|
||||||
|
if signals.verdicts_regressed:
|
||||||
|
note = (" — a surface change occurred in the same run and does not excuse it"
|
||||||
|
if signals.surface_differs else "")
|
||||||
|
return Outcome(Classification.BEHAVIOUR_CHANGED,
|
||||||
|
f"claims that held at baseline no longer hold{note}",
|
||||||
|
signals, failures)
|
||||||
|
|
||||||
|
# 4. An action the system accepted but did not perform is a behaviour
|
||||||
|
# change, observed independently of any claim. It is caught here even
|
||||||
|
# when no claim happens to cover it.
|
||||||
|
if signals.postcondition_met is False:
|
||||||
|
return Outcome(Classification.BEHAVIOUR_CHANGED,
|
||||||
|
"the action was accepted but its declared effect did not occur",
|
||||||
|
signals, failures)
|
||||||
|
|
||||||
|
# 5. Could not act at all. Says nothing about whether the system is correct.
|
||||||
|
if signals.realization_failed:
|
||||||
|
return Outcome(Classification.REALIZATION_FAILED,
|
||||||
|
"the action could not be realized on this surface", signals)
|
||||||
|
if signals.postcondition_met is None:
|
||||||
|
return Outcome(Classification.AMBIGUOUS,
|
||||||
|
"the action's postcondition could not be evaluated", signals)
|
||||||
|
|
||||||
|
# 6. Only now, with every claim intact and the action verified, may a
|
||||||
|
# surface difference be called an adaptation.
|
||||||
|
if signals.surface_differs:
|
||||||
|
return Outcome(Classification.MECHANICAL_ADAPTATION,
|
||||||
|
"the surface changed; every protected claim still holds", signals)
|
||||||
|
|
||||||
|
return Outcome(Classification.UNCHANGED, "no observable difference from baseline",
|
||||||
|
signals)
|
||||||
|
|
@ -74,3 +74,33 @@ class DirectDriver:
|
||||||
return Realization(self.surface.id, mechanics, raised=f"{type(exc).__name__}: {exc}")
|
return Realization(self.surface.id, mechanics, raised=f"{type(exc).__name__}: {exc}")
|
||||||
mechanics["result"] = result
|
mechanics["result"] = result
|
||||||
return Realization(self.surface.id, mechanics)
|
return Realization(self.surface.id, mechanics)
|
||||||
|
|
||||||
|
|
||||||
|
class CompositeDriver:
|
||||||
|
"""Routes each semantic action to the driver for the surface it permits.
|
||||||
|
|
||||||
|
A real journey crosses surfaces: a share happens in the UI, an audit check
|
||||||
|
against the API. Binding one asset to one driver would force every step
|
||||||
|
through the same door and hide exactly the mechanical changes that only
|
||||||
|
appear on one of them.
|
||||||
|
|
||||||
|
Routing is by the action's *permitted* surfaces (D-05), never by which driver
|
||||||
|
happens to be able to do it. A driver that could perform an action it was not
|
||||||
|
permitted to perform is surface substitution, not convenience.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, drivers: dict[str, Any], default: str) -> None:
|
||||||
|
self._drivers = drivers
|
||||||
|
self._default = default
|
||||||
|
self.surface = drivers[default].surface
|
||||||
|
|
||||||
|
def realize(self, actor: Actor, action: SemanticAction) -> Realization:
|
||||||
|
permitted = action.permitted_surfaces or {self._default}
|
||||||
|
for surface_id in sorted(permitted):
|
||||||
|
driver = self._drivers.get(surface_id)
|
||||||
|
if driver is not None:
|
||||||
|
return driver.realize(actor, action)
|
||||||
|
raise UnsupportedAction(
|
||||||
|
f"no driver for any permitted surface of {action.name!r}: "
|
||||||
|
f"{sorted(permitted)}"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ class Runner:
|
||||||
)
|
)
|
||||||
|
|
||||||
judgments: list[Judgment] = []
|
judgments: list[Judgment] = []
|
||||||
|
scenario_sound = True
|
||||||
claims_by_step: dict[str, list] = {}
|
claims_by_step: dict[str, list] = {}
|
||||||
for claim in scenario.use_case.claims:
|
for claim in scenario.use_case.claims:
|
||||||
claims_by_step.setdefault(claim.after_step, []).append(claim)
|
claims_by_step.setdefault(claim.after_step, []).append(claim)
|
||||||
|
|
@ -171,13 +172,35 @@ class Runner:
|
||||||
step.id,
|
step.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Only an action that did not *happen* makes the scenario unsound.
|
||||||
|
# An action that was accepted but did not take effect did happen —
|
||||||
|
# and that is a statement about the system, judged below, not a
|
||||||
|
# reason to stop judging.
|
||||||
|
if refused and not step.expect_refusal:
|
||||||
|
scenario_sound = False
|
||||||
|
|
||||||
# --- invariants after every step -----------------------------
|
# --- invariants after every step -----------------------------
|
||||||
|
# Invariants are judged regardless: they are supposed to hold at all
|
||||||
|
# times, however far the scenario got.
|
||||||
for invariant in scenario.use_case.invariants:
|
for invariant in scenario.use_case.invariants:
|
||||||
judgments.append(self._oracle.judge(invariant, snapshot, step.id))
|
judgments.append(self._oracle.judge(invariant, snapshot, step.id))
|
||||||
|
|
||||||
# --- claims attached to this step ----------------------------
|
# --- claims attached to this step ----------------------------
|
||||||
|
# Claims describe the state a *completed* scenario should reach. If a
|
||||||
|
# step did not happen, a claim about the state after it is not
|
||||||
|
# failing — it is unevaluable. Reporting FAIL there would accuse the
|
||||||
|
# system of a defect on the strength of the test's own inability to
|
||||||
|
# act, which is the mirror image of a false adaptation and just as
|
||||||
|
# dishonest.
|
||||||
for claim in claims_by_step.get(step.id, ()):
|
for claim in claims_by_step.get(step.id, ()):
|
||||||
judgments.append(self._oracle.judge(claim, snapshot, step.id))
|
if scenario_sound:
|
||||||
|
judgments.append(self._oracle.judge(claim, snapshot, step.id))
|
||||||
|
else:
|
||||||
|
judgments.append(Judgment(
|
||||||
|
claim.id, claim.text, Verdict.INCONCLUSIVE, step.id,
|
||||||
|
{"reason": "an earlier step in this scenario did not complete, "
|
||||||
|
"so the state this claim describes was never reached"},
|
||||||
|
))
|
||||||
|
|
||||||
pack.verdicts = [j.as_dict() for j in judgments]
|
pack.verdicts = [j.as_dict() for j in judgments]
|
||||||
pack.finished_at = datetime.now(timezone.utc).isoformat()
|
pack.finished_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
195
tests/test_classification.py
Normal file
195
tests/test_classification.py
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
"""The classifier, measured against the labelled catalogue — and attacked.
|
||||||
|
|
||||||
|
`test_response_matrix` samples benign and hostile cases alike. The E-003 group
|
||||||
|
below does something different: it *tries to make the framework unsafe*. An
|
||||||
|
experiment that only samples cases chosen by the same person who wrote the
|
||||||
|
implementation cannot establish a safety property.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from testdriver import Runner, Verdict
|
||||||
|
from testdriver.classification import (
|
||||||
|
Classification, SAFE_TO_ACCEPT, classify,
|
||||||
|
)
|
||||||
|
from testdriver.provenance import InadmissibleProvenance, Provenance
|
||||||
|
from testdriver.intent import Claim
|
||||||
|
from lab.mutations import CATALOGUE
|
||||||
|
from scenarios.full_journey import build_journey, journey_lab_server
|
||||||
|
from tests.selfverification.checks import check_intent_independence
|
||||||
|
|
||||||
|
|
||||||
|
def pack_for(*mutations: str, observer_factory=None) -> dict:
|
||||||
|
with journey_lab_server(*mutations) as (app, tokens, base_url):
|
||||||
|
world, driver, observer, asset, oracle = build_journey(app, tokens, base_url)
|
||||||
|
if observer_factory is not None:
|
||||||
|
observer = observer_factory(observer)
|
||||||
|
return json.loads(Runner(world, driver, observer, oracle).run(asset).evidence.to_json())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def baseline() -> dict:
|
||||||
|
return pack_for()
|
||||||
|
|
||||||
|
|
||||||
|
# --- the response matrix --------------------------------------------------
|
||||||
|
|
||||||
|
EXPECTED = {
|
||||||
|
"M01": Classification.MECHANICAL_ADAPTATION,
|
||||||
|
"M02": Classification.MECHANICAL_ADAPTATION,
|
||||||
|
"M03": Classification.UNCHANGED,
|
||||||
|
"M04": Classification.MECHANICAL_ADAPTATION,
|
||||||
|
"M05": Classification.UNCHANGED,
|
||||||
|
"M06": Classification.MECHANICAL_ADAPTATION,
|
||||||
|
"M07": Classification.MECHANICAL_ADAPTATION,
|
||||||
|
"M08": Classification.MECHANICAL_ADAPTATION,
|
||||||
|
"M09": Classification.UNCHANGED,
|
||||||
|
"M10": Classification.UNCHANGED,
|
||||||
|
"M21": Classification.MECHANICAL_ADAPTATION,
|
||||||
|
"M22": Classification.AMBIGUOUS,
|
||||||
|
"M11": Classification.BEHAVIOUR_CHANGED,
|
||||||
|
"M12": Classification.BEHAVIOUR_CHANGED,
|
||||||
|
"M13": Classification.UNCHANGED,
|
||||||
|
"M14": Classification.UNCHANGED,
|
||||||
|
"M15": Classification.BEHAVIOUR_CHANGED,
|
||||||
|
"M16": Classification.BEHAVIOUR_CHANGED,
|
||||||
|
"M17": Classification.BEHAVIOUR_CHANGED,
|
||||||
|
"M18": Classification.BEHAVIOUR_CHANGED,
|
||||||
|
"M19": Classification.BEHAVIOUR_CHANGED,
|
||||||
|
"M20": Classification.BEHAVIOUR_CHANGED,
|
||||||
|
"M23": Classification.AMBIGUOUS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mutation_id", sorted(EXPECTED))
|
||||||
|
def test_response_matrix(baseline, mutation_id):
|
||||||
|
assert classify(baseline, pack_for(mutation_id)).classification is EXPECTED[mutation_id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_baseline_classifies_as_unchanged(baseline):
|
||||||
|
assert classify(baseline, pack_for()).classification is Classification.UNCHANGED
|
||||||
|
|
||||||
|
|
||||||
|
# --- the metric that matters ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_false_adaptation_rate_is_zero(baseline):
|
||||||
|
"""The project's existential safety metric.
|
||||||
|
|
||||||
|
Not "low". Zero. A non-zero result is a stop-and-redesign signal, because a
|
||||||
|
framework that normalizes a real defect once has spent the credibility it
|
||||||
|
exists to accumulate.
|
||||||
|
"""
|
||||||
|
false_adaptations = [
|
||||||
|
mutation.id for mutation in CATALOGUE
|
||||||
|
if mutation.label == "DEFECT"
|
||||||
|
and classify(baseline, pack_for(mutation.id)).safe_to_accept
|
||||||
|
]
|
||||||
|
assert false_adaptations == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_defect_is_escalated(baseline):
|
||||||
|
for mutation in CATALOGUE:
|
||||||
|
if mutation.label != "DEFECT":
|
||||||
|
continue
|
||||||
|
outcome = classify(baseline, pack_for(mutation.id))
|
||||||
|
assert not outcome.safe_to_accept, f"{mutation.id}: {outcome.reason}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mechanical_changes_are_mostly_absorbed(baseline):
|
||||||
|
"""Adaptation has to be *useful*, not merely safe.
|
||||||
|
|
||||||
|
A classifier that escalated everything would score a perfect False Adaptation
|
||||||
|
Rate and be worthless. This asserts the other side of the trade.
|
||||||
|
"""
|
||||||
|
mechanical = [m for m in CATALOGUE if m.label == "MECHANICAL"]
|
||||||
|
absorbed = [
|
||||||
|
m.id for m in mechanical
|
||||||
|
if classify(baseline, pack_for(m.id)).safe_to_accept
|
||||||
|
]
|
||||||
|
assert len(absorbed) >= len(mechanical) - 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_classification_is_outside_the_defined_space(baseline):
|
||||||
|
for mutation in CATALOGUE:
|
||||||
|
outcome = classify(baseline, pack_for(mutation.id))
|
||||||
|
assert isinstance(outcome.classification, Classification)
|
||||||
|
assert outcome.reason
|
||||||
|
|
||||||
|
|
||||||
|
# --- E-003: attacking the boundary ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_attack_surface_substitution(baseline):
|
||||||
|
"""M23: the control is gone from the UI; the API endpoint is still open.
|
||||||
|
|
||||||
|
The most plausible concrete route to a false adaptation. A driver that
|
||||||
|
'recovers' by calling the API has routed around a control, not adapted to a
|
||||||
|
change — and would score a successful mechanical adaptation while an
|
||||||
|
authorization control sat broken.
|
||||||
|
"""
|
||||||
|
outcome = classify(baseline, pack_for("M23"))
|
||||||
|
assert not outcome.safe_to_accept
|
||||||
|
assert outcome.classification is Classification.AMBIGUOUS
|
||||||
|
|
||||||
|
|
||||||
|
def test_attack_concurrent_mechanical_and_defect(baseline):
|
||||||
|
"""Decision-table row 3. Coincidence is not exoneration.
|
||||||
|
|
||||||
|
This is the case a naive self-healing tool gets wrong: it sees a surface
|
||||||
|
change, explains the failure with it, and adapts.
|
||||||
|
"""
|
||||||
|
for combination in (("M01", "M15"), ("M02", "M17"), ("M21", "M20")):
|
||||||
|
outcome = classify(baseline, pack_for(*combination))
|
||||||
|
assert outcome.classification is Classification.BEHAVIOUR_CHANGED
|
||||||
|
assert not outcome.safe_to_accept
|
||||||
|
assert "does not excuse it" in outcome.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_attack_evidence_starvation(baseline):
|
||||||
|
"""Cut the independent observation channel and the answer must be AMBIGUOUS.
|
||||||
|
|
||||||
|
Never a pass, never an adaptation. Evidence starvation is the condition under
|
||||||
|
which a framework is most tempted to fall back on the actor's own account of
|
||||||
|
what happened.
|
||||||
|
"""
|
||||||
|
class Starved:
|
||||||
|
def __init__(self, real):
|
||||||
|
self._real, self._calls, self.name = real, 0, real.name
|
||||||
|
self.watches = real.watches
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
self._calls += 1
|
||||||
|
return self._real.snapshot() if self._calls < 2 else {}
|
||||||
|
|
||||||
|
outcome = classify(baseline, pack_for(observer_factory=Starved))
|
||||||
|
assert outcome.classification is Classification.AMBIGUOUS
|
||||||
|
assert not outcome.safe_to_accept
|
||||||
|
|
||||||
|
|
||||||
|
def test_attack_provenance_laundering():
|
||||||
|
"""Intent derived from the implementation must not become a claim.
|
||||||
|
|
||||||
|
Rejected at authoring time, and caught again in the record if it ever got
|
||||||
|
past — belt and braces, because this one cannot be noticed by looking at
|
||||||
|
behaviour.
|
||||||
|
"""
|
||||||
|
with pytest.raises(InadmissibleProvenance):
|
||||||
|
Claim("c-laundered", "whatever the system does",
|
||||||
|
Provenance.AGENT_FROM_IMPLEMENTATION, lambda obs: True, after_step="s1")
|
||||||
|
|
||||||
|
tampered = pack_for("M15")
|
||||||
|
tampered["provenance_index"]["c-bob-revoked"] = "agent-from-implementation"
|
||||||
|
assert check_intent_independence(tampered)
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_to_accept_is_a_closed_set():
|
||||||
|
"""Only two outcomes may proceed without a human. Widening this set is the
|
||||||
|
single easiest way to destroy the safety property, so it is asserted."""
|
||||||
|
assert SAFE_TO_ACCEPT == {
|
||||||
|
Classification.UNCHANGED, Classification.MECHANICAL_ADAPTATION,
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ def run_against(*mutations: str):
|
||||||
|
|
||||||
def test_catalogue_is_large_enough_to_support_a_rate():
|
def test_catalogue_is_large_enough_to_support_a_rate():
|
||||||
"""Six mutations cannot support precision or recall. Twenty can begin to."""
|
"""Six mutations cannot support precision or recall. Twenty can begin to."""
|
||||||
assert len(CATALOGUE) >= 20
|
assert len(CATALOGUE) >= 20 # 23 as of T08
|
||||||
|
|
||||||
|
|
||||||
def test_every_mutation_is_labelled_and_reasoned():
|
def test_every_mutation_is_labelled_and_reasoned():
|
||||||
|
|
@ -82,10 +82,19 @@ EXPECTED_VERDICT = {
|
||||||
"M18": Verdict.FAIL, "M19": Verdict.FAIL, "M20": Verdict.FAIL,
|
"M18": Verdict.FAIL, "M19": Verdict.FAIL, "M20": Verdict.FAIL,
|
||||||
}
|
}
|
||||||
|
|
||||||
# The two SEMANTIC mutations the reference scenario cannot see, and why.
|
# Mutations the reference scenario cannot see, and why. Coverage is scoped to
|
||||||
KNOWN_INERT = {
|
# what a scenario asserts (F-0002) *and* to the surfaces it touches: this
|
||||||
|
# scenario is API-only, so a defect that lives in the UI is outside its reach.
|
||||||
|
# Declaring them is mandatory — `test_out_of_scope_mutations_are_declared`
|
||||||
|
# fails on any invisible mutation that is not named here.
|
||||||
|
OUT_OF_SCOPE = {
|
||||||
"M13": "only affects grants that omit a permission; the scenario passes READ explicitly",
|
"M13": "only affects grants that omit a permission; the scenario passes READ explicitly",
|
||||||
"M14": "a change of intent with no change of code; nothing observable moved",
|
"M14": "a change of intent with no change of code; nothing observable moved",
|
||||||
|
"M21": "a UI mutation; this scenario never loads the UI",
|
||||||
|
"M22": "a UI mutation; this scenario never loads the UI",
|
||||||
|
"M23": "a UI-surface defect; this scenario is API-only. Covered by the "
|
||||||
|
"cross-surface journey in tests/test_classification.py, where it is "
|
||||||
|
"the E-003 surface-substitution attack.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -105,26 +114,43 @@ def test_no_mechanical_mutation_changes_the_verdict():
|
||||||
assert run_against(mutation.id).verdict is Verdict.PASS, mutation.id
|
assert run_against(mutation.id).verdict is Verdict.PASS, mutation.id
|
||||||
|
|
||||||
|
|
||||||
def test_every_defect_is_detected():
|
def test_every_in_scope_defect_is_detected():
|
||||||
"""The floor of the whole project. A defect the lab cannot surface is a
|
"""The floor of the whole project. A defect the lab cannot surface is a
|
||||||
defect no later classifier can be measured against."""
|
defect no later classifier can be measured against."""
|
||||||
missed = [
|
missed = [
|
||||||
m.id for m in CATALOGUE
|
m.id for m in CATALOGUE
|
||||||
if m.label == "DEFECT" and run_against(m.id).verdict is Verdict.PASS
|
if m.label == "DEFECT"
|
||||||
|
and m.id not in OUT_OF_SCOPE
|
||||||
|
and run_against(m.id).verdict is Verdict.PASS
|
||||||
]
|
]
|
||||||
assert missed == [], f"undetected seeded defects: {missed}"
|
assert missed == [], f"undetected seeded defects: {missed}"
|
||||||
|
|
||||||
|
|
||||||
def test_inert_semantic_mutations_are_declared():
|
def test_out_of_scope_mutations_are_declared():
|
||||||
"""A mutation the scenario cannot see must be named, not silently ignored."""
|
"""A mutation the scenario cannot see must be named, not silently ignored.
|
||||||
|
|
||||||
|
Applies to defects as much as to semantic changes — an undeclared invisible
|
||||||
|
defect is precisely how a suite comes to look greener than it is.
|
||||||
|
"""
|
||||||
for mutation in CATALOGUE:
|
for mutation in CATALOGUE:
|
||||||
if mutation.label != "SEMANTIC":
|
if run_against(mutation.id).verdict is not Verdict.PASS:
|
||||||
continue
|
continue
|
||||||
if run_against(mutation.id).verdict is Verdict.PASS:
|
if mutation.label == "MECHANICAL":
|
||||||
assert mutation.id in KNOWN_INERT, (
|
continue # passing is the correct outcome for these
|
||||||
f"{mutation.id} is invisible to the reference scenario and "
|
assert mutation.id in OUT_OF_SCOPE, (
|
||||||
"undeclared — either cover it or record why not"
|
f"{mutation.id} ({mutation.label}) is invisible to the reference "
|
||||||
)
|
"scenario and undeclared — either cover it or record why not"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_declared_out_of_scope_mutations_really_are_invisible():
|
||||||
|
"""Stale declarations rot silently. If a mutation becomes visible, the
|
||||||
|
declaration must be removed rather than left as a standing excuse."""
|
||||||
|
for mutation_id in OUT_OF_SCOPE:
|
||||||
|
assert run_against(mutation_id).verdict is Verdict.PASS, (
|
||||||
|
f"{mutation_id} is declared out of scope but the reference scenario "
|
||||||
|
"now detects it — remove the declaration"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_deferred_revoke_and_revoke_race_are_behaviourally_identical():
|
def test_deferred_revoke_and_revoke_race_are_behaviourally_identical():
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,7 @@ The headline result is a narrowing, not a confirmation:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TD-WP-0002-T08
|
id: TD-WP-0002-T08
|
||||||
status: todo
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "e2403d3c-bc35-5dd3-b1c8-2474663e06d0"
|
state_hub_task_id: "e2403d3c-bc35-5dd3-b1c8-2474663e06d0"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue