Merge pull request 'Journey Matcher — Delta -> Journey (ADR-011)' (#34) from feat/journey-matcher into main
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
"""Journey Matcher — the Delta -> Journey function of the Capability Delta Engine.
|
||||
|
||||
The third independent function of the pipeline (after Company 2A `Evidence -> Capability` and RS-005
|
||||
`Capability -> Delta`): given ONLY the Capability Delta, rank the known journeys that best EXPLAIN it.
|
||||
A Journey is an EXPLANATION of the delta, not its cause — order is `Goal -> Required -> Delta -> Journey`.
|
||||
|
||||
Deliberately dumb + deterministic (pure set overlap; no ML/embeddings/LLM), fully auditable, signatures
|
||||
INJECTED (certificate-agnostic capability clusters). No new corpus, no graph (freeze v1.0). The Matcher
|
||||
is sanctioned as the last architectural building block; everything after is knowledge work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .engine import match_journeys
|
||||
from .schemas import (
|
||||
JourneyMatch,
|
||||
JourneyMatchReason,
|
||||
JourneyMatchResult,
|
||||
JourneySignature,
|
||||
MatchContext,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"match_journeys",
|
||||
"JourneySignature",
|
||||
"MatchContext",
|
||||
"JourneyMatch",
|
||||
"JourneyMatchReason",
|
||||
"JourneyMatchResult",
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Journey Matcher — the Delta -> Journey function of the Capability Delta Engine.
|
||||
|
||||
Three INDEPENDENT functions now compose the pipeline, each a different problem, all interchangeable:
|
||||
1. Evidence -> Capability (Company 2A)
|
||||
2. Capability -> Delta (RS-005, transition_reasoning)
|
||||
3. Delta -> Journey (THIS module)
|
||||
|
||||
The paradigm shift: a Journey is no longer the CAUSE (Goal -> Journey -> Delta) but the EXPLANATION
|
||||
(Goal -> Required -> Delta -> Journey). The matcher does NOT look at certifications, regulations,
|
||||
tenders, OEM specs or the goal — it looks ONLY at the Capability Delta and asks: which known journeys
|
||||
describe exactly this delta? Output is a ranked, auditable explanation ("Journey A explains 82% of the
|
||||
delta, because 8 of 10 missing capabilities are identical, same target type, ...").
|
||||
|
||||
Deliberately DUMB and deterministic: pure set overlap, NO ML, NO embeddings, NO LLM. A learning ranker
|
||||
can be layered ON TOP later; this core stays auditable. Journey signatures are INJECTED (certificate-
|
||||
agnostic capability clusters), never loaded here — the engine stays hermetic. No new corpus, no
|
||||
graph/meta-model class (freeze v1.0). Python 3.9 compatible.
|
||||
|
||||
Honesty: `score` is the share of the DELTA a journey explains (recall over the customer's missing
|
||||
capabilities), never a "fit" or a compliance verdict. `journey_only` documents where a journey reaches
|
||||
BEYOND this delta, so a broad journey that explains everything is not silently preferred.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Sequence
|
||||
|
||||
from .schemas import (
|
||||
JourneyMatch,
|
||||
JourneyMatchReason,
|
||||
JourneyMatchResult,
|
||||
JourneySignature,
|
||||
MatchContext,
|
||||
)
|
||||
|
||||
|
||||
def _context_signals(journey: JourneySignature, context: Optional[MatchContext]) -> List[str]:
|
||||
"""Corroborating reasons only — these are documented, they never change the score."""
|
||||
if context is None:
|
||||
return []
|
||||
signals: List[str] = []
|
||||
if context.target_type and journey.target_type and context.target_type == journey.target_type:
|
||||
signals.append("gleiche Zielart")
|
||||
if context.industry and journey.industry and context.industry == journey.industry:
|
||||
signals.append("gleiche Branche")
|
||||
if context.product_type and journey.product_type and context.product_type == journey.product_type:
|
||||
signals.append("gleicher Produkttyp")
|
||||
return signals
|
||||
|
||||
|
||||
def match_journeys(
|
||||
delta: Sequence[str],
|
||||
journeys: Sequence[JourneySignature],
|
||||
context: Optional[MatchContext] = None,
|
||||
) -> JourneyMatchResult:
|
||||
"""Rank known journeys by the share of the Capability Delta they EXPLAIN.
|
||||
|
||||
`delta` = the customer's MISSING capabilities (from RS-005). `journeys` = injected, certificate-
|
||||
agnostic signatures. score = |delta INTERSECT pattern| / |delta|. Ranking is deterministic:
|
||||
score desc, then context-signal count desc (corroboration only), then journey_id asc. Context
|
||||
never changes the score — only the documented reasons. Pure; no I/O; computed-not-stored.
|
||||
"""
|
||||
delta_set = set(delta)
|
||||
n = len(delta_set)
|
||||
matches: List[JourneyMatch] = []
|
||||
for j in journeys:
|
||||
pattern = set(j.capability_pattern)
|
||||
matched = sorted(delta_set & pattern)
|
||||
score = (len(matched) / n) if n else 0.0
|
||||
signals = _context_signals(j, context)
|
||||
reason = JourneyMatchReason(
|
||||
matched_capabilities=matched,
|
||||
unexplained_delta=sorted(delta_set - pattern),
|
||||
journey_only=sorted(pattern - delta_set),
|
||||
context_signals=signals,
|
||||
)
|
||||
matches.append(
|
||||
JourneyMatch(
|
||||
journey_id=j.journey_id,
|
||||
label=j.label,
|
||||
score=round(score, 2),
|
||||
explains="%d von %d fehlenden Capabilities" % (len(matched), n),
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
matches.sort(key=lambda m: (-m.score, -len(m.reason.context_signals), m.journey_id))
|
||||
best = matches[0] if matches and matches[0].score > 0.0 else None
|
||||
headline = (
|
||||
"%d Journeys erklaeren das Delta; beste: %s (%d%% des Deltas)"
|
||||
% (sum(1 for m in matches if m.score > 0.0), best.label, round(best.score * 100))
|
||||
if best
|
||||
else "Keine bekannte Journey erklaert dieses Delta (neue Journey-Kandidatin)"
|
||||
)
|
||||
return JourneyMatchResult(delta_size=n, matches=matches, best=best, headline=headline)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Schemas for the Journey Matcher — the Delta -> Journey function of the Capability Delta Engine.
|
||||
|
||||
Derived views (computed-not-stored): nothing here is persisted; every match is recomputed from the
|
||||
input delta + injected journey signatures each call. No new corpus, no graph (freeze v1.0).
|
||||
Python 3.9 compatible (no `|` unions).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class JourneySignature(BaseModel):
|
||||
"""A known journey described ONLY by its capability pattern (Input cluster -> Output cluster).
|
||||
|
||||
Deliberately certificate-/regulation-agnostic: the match uses `capability_pattern` alone. `label`
|
||||
and the context fields exist for the human-auditable explanation, NEVER for the score. (Today the
|
||||
signatures are derived from the transition patterns; the IDs like "ISO27001->CRA" are just one way
|
||||
to describe the clusters — the matcher never reads them.)
|
||||
"""
|
||||
|
||||
journey_id: str
|
||||
label: str
|
||||
capability_pattern: List[str] = Field(default_factory=list) # OUTPUT cluster: the delta this journey is about
|
||||
assumed_capabilities: List[str] = Field(default_factory=list) # INPUT cluster: typically already present
|
||||
industry: Optional[str] = None
|
||||
product_type: Optional[str] = None
|
||||
target_type: Optional[str] = None # context only: regulation / certification / contract / environmental
|
||||
|
||||
|
||||
class MatchContext(BaseModel):
|
||||
"""Optional corroborating context — surfaced as documented reasons, never part of the score."""
|
||||
|
||||
industry: Optional[str] = None
|
||||
product_type: Optional[str] = None
|
||||
target_type: Optional[str] = None
|
||||
|
||||
|
||||
class JourneyMatchReason(BaseModel):
|
||||
"""The auditable WHY behind one match — everything a reviewer needs, no opaque score."""
|
||||
|
||||
matched_capabilities: List[str] = Field(default_factory=list) # delta INTERSECT pattern (what it explains)
|
||||
unexplained_delta: List[str] = Field(default_factory=list) # delta - pattern (what it does NOT explain)
|
||||
journey_only: List[str] = Field(default_factory=list) # pattern - delta (journey covers, not needed here)
|
||||
context_signals: List[str] = Field(default_factory=list) # "gleiche Zielart", "gleiche Branche", ...
|
||||
|
||||
|
||||
class JourneyMatch(BaseModel):
|
||||
"""One known journey, ranked by how much of the delta it EXPLAINS (not how well it 'fits')."""
|
||||
|
||||
journey_id: str
|
||||
label: str
|
||||
score: float = 0.0 # |delta INTERSECT pattern| / |delta|, 0..1: share of the delta explained
|
||||
explains: str = "" # "8 von 10 fehlenden Capabilities"
|
||||
reason: JourneyMatchReason
|
||||
|
||||
|
||||
class JourneyMatchResult(BaseModel):
|
||||
"""Ranked known journeys that EXPLAIN a Capability Delta. Journey = explanation, not cause."""
|
||||
|
||||
delta_size: int = 0
|
||||
matches: List[JourneyMatch] = Field(default_factory=list) # ranked desc by score
|
||||
best: Optional[JourneyMatch] = None
|
||||
headline: str = ""
|
||||
@@ -0,0 +1,28 @@
|
||||
# Journey Matcher — Delta -> Journey (an echten Pattern validiert)
|
||||
|
||||
_Der Matcher fragt NICHT „welche Journey passt?", sondern „welche bekannten Journeys ERKLÄREN dieses Capability Delta?". Er sieht nur das Delta — keine Zertifikate, kein Regelwerk, kein Ziel. Journey = Erklärung, nicht Ursache. Deterministisch, kein ML/Embedding/LLM. Synthetischer Kunde, keine echten Namen._
|
||||
|
||||
## Eingang: ein echtes Capability Delta
|
||||
- Multi-zertifiziertes Unternehmen will **CRA + MaschinenVO** → **9 fehlende Capabilities** (aus RS-005).
|
||||
- Der Matcher bekommt **nur diese 9 Capabilities** — sonst nichts.
|
||||
|
||||
## Delta -> Journey: Rangliste (Anteil des Deltas, den die Journey erklärt)
|
||||
> 3 Journeys erklaeren das Delta; beste: ISO27001 -> CRA + MaschinenVO (100% des Deltas)
|
||||
|
||||
| Journey (Capability-Cluster) | erklärt | Anteil |
|
||||
|---|---|---|
|
||||
| **ISO27001 -> CRA + MaschinenVO** | 9 von 9 fehlenden Capabilities | 100% |
|
||||
| **ISO27001 -> CRA** | 5 von 9 fehlenden Capabilities | 56% |
|
||||
| **ISO9001 -> CRA** | 4 von 9 fehlenden Capabilities | 44% |
|
||||
| **ISMS -> TISAX** | 0 von 9 fehlenden Capabilities | 0% |
|
||||
|
||||
## Warum „ISO27001 -> CRA + MaschinenVO"? — auditierbar, keine Blackbox
|
||||
- **Erklärte Capabilities (9):** `machine_safety_risk_assessment`, `mechanical_safety_and_guards`, `operating_instructions_and_safety_information`, `product_cyber_risk_assessment`, `protection_against_corruption_of_safety_functions`, `public_security_advisories` …
|
||||
- **Nicht erklärt (Rest-Delta):** — (Journey erklärt das GESAMTE Delta)
|
||||
- **Journey reicht darüber hinaus:** `ce_conformity_assessment_and_technical_documentation`, `coordinated_vulnerability_disclosure`, `exploited_vuln_and_incident_reporting`
|
||||
- **Kontext-Signale:** gleiche Zielart
|
||||
|
||||
## Der Paradigmenwechsel
|
||||
|
||||
> Reihenfolge ist jetzt **`Goal → Required → Delta → Journey`**, nicht mehr `Goal → Journey → Delta`. Die Journey ist die **Erklärung** des Deltas. Der Matcher ist bewusst **dumm + deterministisch** (reine Mengenüberlappung) und damit auditierbar; ein lernendes Ranking kann später DAVOR gesetzt werden. Drei austauschbare Funktionen: `Evidence→Capability` (Company 2A) · `Capability→Delta` (RS-005) · **`Delta→Journey` (dieser Matcher)**. In keiner kommt „Regulation" als Sonderfall vor — CRA, TISAX, Ausschreibung, OEM-Spec und Umweltziel sind nur verschiedene Quellen des Required State.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# ruff: noqa
|
||||
# mypy: ignore-errors
|
||||
"""Journey Matcher demo — Delta -> Journey on the REAL transition patterns.
|
||||
|
||||
Validates the new matcher end-to-end: take a real Capability Delta (a multi-certified company that
|
||||
wants CRA + MaschinenVO), then rank the KNOWN journeys purely by how much of THAT delta each explains.
|
||||
The matcher never looks at the certificates, the regulation or the goal — only at the delta. The
|
||||
journey is the EXPLANATION of the delta, not its cause (order: Goal -> Required -> Delta -> Journey).
|
||||
|
||||
Journey signatures are derived from the transition-pattern YAMLs here (non-core), then injected into the
|
||||
hermetic engine. Synthetic company (NO real names). Non-runtime -> no deploy.
|
||||
Run: cd backend-compliance && PYTHONPATH=. python3 reference_scenarios/journey_matcher_demo.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import yaml
|
||||
|
||||
from compliance.company import (
|
||||
CompanyContext, Certification, CapabilityMappingEntry, build_company_profile,
|
||||
)
|
||||
from compliance.reasoning.enums import Confidence
|
||||
from compliance.transition_reasoning import (
|
||||
TransitionContext, TransitionGoal, TargetRequirement, assess_transition, CoverageStatus,
|
||||
)
|
||||
from compliance.journey_matcher import JourneySignature, MatchContext, match_journeys
|
||||
|
||||
OUT = []
|
||||
|
||||
|
||||
def w(s=""):
|
||||
OUT.append(s)
|
||||
|
||||
|
||||
_K = os.path.join(os.path.dirname(__file__), "..", "knowledge", "transition_patterns")
|
||||
_PATTERNS = {
|
||||
"transition_pattern_iso27001_to_cra_maschinenvo_v1.yaml": ("ISO27001 -> CRA + MaschinenVO", "regulation"),
|
||||
"transition_pattern_iso27001_to_cra_v1.yaml": ("ISO27001 -> CRA", "regulation"),
|
||||
"transition_pattern_iso9001_to_cra_v1.yaml": ("ISO9001 -> CRA", "regulation"),
|
||||
"transition_pattern_isms_to_tisax_v1.yaml": ("ISMS -> TISAX", "certification"),
|
||||
}
|
||||
|
||||
|
||||
def _load(name):
|
||||
return yaml.safe_load(open(os.path.join(_K, name), encoding="utf-8"))
|
||||
|
||||
|
||||
# ── Journey library: signatures = capability CLUSTERS (the matcher never reads the IDs) ──────
|
||||
journeys = []
|
||||
for fname, (label, ttype) in _PATTERNS.items():
|
||||
p = _load(fname)
|
||||
journeys.append(JourneySignature(
|
||||
journey_id=p.get("id", fname),
|
||||
label=label,
|
||||
capability_pattern=[d["capability"] for d in p["delta_requirements"]], # OUTPUT cluster
|
||||
assumed_capabilities=[a["capability"] for a in p["likely_covered"]], # INPUT cluster
|
||||
target_type=ttype,
|
||||
))
|
||||
|
||||
# ── A real Capability Delta: multi-certified company that wants CRA + MaschinenVO ────────────
|
||||
CP = _load("transition_pattern_iso27001_to_cra_maschinenvo_v1.yaml")
|
||||
infosec = [a["capability"] for a in CP["likely_covered"]]
|
||||
cmap = {
|
||||
"ISO27001": CapabilityMappingEntry(capability_ids=infosec, confidence=Confidence.MEDIUM),
|
||||
"PSIRT": CapabilityMappingEntry(capability_ids=["coordinated_vulnerability_disclosure",
|
||||
"exploited_vuln_and_incident_reporting"], confidence=Confidence.HIGH),
|
||||
"ISO9001": CapabilityMappingEntry(capability_ids=["ce_conformity_assessment_and_technical_documentation"],
|
||||
confidence=Confidence.MEDIUM),
|
||||
}
|
||||
profile = build_company_profile(
|
||||
CompanyContext(company_id="d", certifications=[Certification(certification_id=k) for k in cmap]), cmap)
|
||||
reqs = [TargetRequirement(capability_id=a["capability"]) for a in CP["likely_covered"]]
|
||||
reqs += [TargetRequirement(capability_id=d["capability"]) for d in CP["delta_requirements"]]
|
||||
assess = assess_transition(TransitionContext(company_id="d", target=TransitionGoal(target_id="CRA+MaschinenVO")), reqs, profile)
|
||||
delta = sorted({c.capability_id for c in assess.coverage if c.status == CoverageStatus.MISSING})
|
||||
|
||||
# ── Delta -> Journey: rank the known journeys that EXPLAIN this delta ────────────────────────
|
||||
result = match_journeys(delta, journeys, MatchContext(target_type="regulation"))
|
||||
|
||||
w("# Journey Matcher — Delta -> Journey (an echten Pattern validiert)")
|
||||
w("")
|
||||
w('_Der Matcher fragt NICHT „welche Journey passt?", sondern „welche bekannten Journeys ERKLÄREN dieses Capability Delta?". Er sieht nur das Delta — keine Zertifikate, kein Regelwerk, kein Ziel. Journey = Erklärung, nicht Ursache. Deterministisch, kein ML/Embedding/LLM. Synthetischer Kunde, keine echten Namen._')
|
||||
w("")
|
||||
w("## Eingang: ein echtes Capability Delta")
|
||||
w("- Multi-zertifiziertes Unternehmen will **CRA + MaschinenVO** → **%d fehlende Capabilities** (aus RS-005)." % len(delta))
|
||||
w("- Der Matcher bekommt **nur diese %d Capabilities** — sonst nichts." % len(delta))
|
||||
w("")
|
||||
w("## Delta -> Journey: Rangliste (Anteil des Deltas, den die Journey erklärt)")
|
||||
w("> %s" % result.headline)
|
||||
w("")
|
||||
w("| Journey (Capability-Cluster) | erklärt | Anteil |")
|
||||
w("|---|---|---|")
|
||||
for m in result.matches:
|
||||
w("| **%s** | %s | %d%% |" % (m.label, m.explains, round(m.score * 100)))
|
||||
w("")
|
||||
b = result.best
|
||||
w('## Warum „%s"? — auditierbar, keine Blackbox' % b.label)
|
||||
w("- **Erklärte Capabilities (%d):** %s" % (len(b.reason.matched_capabilities), ", ".join("`%s`" % c for c in b.reason.matched_capabilities[:6]) + (" …" if len(b.reason.matched_capabilities) > 6 else "")))
|
||||
w("- **Nicht erklärt (Rest-Delta):** %s" % (", ".join("`%s`" % c for c in b.reason.unexplained_delta) or "— (Journey erklärt das GESAMTE Delta)"))
|
||||
w("- **Journey reicht darüber hinaus:** %s" % (", ".join("`%s`" % c for c in b.reason.journey_only) or "—"))
|
||||
w("- **Kontext-Signale:** %s" % (", ".join(b.reason.context_signals) or "—"))
|
||||
w("")
|
||||
w("## Der Paradigmenwechsel")
|
||||
w("")
|
||||
w('> Reihenfolge ist jetzt **`Goal → Required → Delta → Journey`**, nicht mehr `Goal → Journey → Delta`. Die Journey ist die **Erklärung** des Deltas. Der Matcher ist bewusst **dumm + deterministisch** (reine Mengenüberlappung) und damit auditierbar; ein lernendes Ranking kann später DAVOR gesetzt werden. Drei austauschbare Funktionen: `Evidence→Capability` (Company 2A) · `Capability→Delta` (RS-005) · **`Delta→Journey` (dieser Matcher)**. In keiner kommt „Regulation" als Sonderfall vor — CRA, TISAX, Ausschreibung, OEM-Spec und Umweltziel sind nur verschiedene Quellen des Required State.')
|
||||
w("")
|
||||
|
||||
print("\n".join(OUT))
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Unit tests for the Journey Matcher (Delta -> Journey).
|
||||
|
||||
The matcher ranks known journeys by the share of the Capability Delta they EXPLAIN, using ONLY the
|
||||
delta and injected capability-cluster signatures — deterministic, auditable, no ML. These tests pin
|
||||
the score semantics (recall over the delta), the ranking order, the audit reasons, and that context
|
||||
corroborates without ever changing the score.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from compliance.journey_matcher import (
|
||||
JourneySignature,
|
||||
MatchContext,
|
||||
match_journeys,
|
||||
)
|
||||
|
||||
|
||||
def _sig(jid, pattern, **kw):
|
||||
return JourneySignature(journey_id=jid, label=jid, capability_pattern=pattern, **kw)
|
||||
|
||||
|
||||
def test_score_is_share_of_delta_explained():
|
||||
delta = ["a", "b", "c", "d", "e"]
|
||||
j = _sig("J", ["a", "b", "c", "d"]) # explains 4 of 5
|
||||
res = match_journeys(delta, [j])
|
||||
assert res.matches[0].score == 0.8
|
||||
assert res.matches[0].explains == "4 von 5 fehlenden Capabilities"
|
||||
|
||||
|
||||
def test_ranking_orders_by_explanatory_power():
|
||||
delta = ["a", "b", "c", "d"]
|
||||
journeys = [
|
||||
_sig("low", ["a"]), # 1/4
|
||||
_sig("high", ["a", "b", "c"]), # 3/4
|
||||
_sig("mid", ["a", "b"]), # 2/4
|
||||
]
|
||||
res = match_journeys(delta, journeys)
|
||||
assert [m.journey_id for m in res.matches] == ["high", "mid", "low"]
|
||||
assert res.best.journey_id == "high"
|
||||
|
||||
|
||||
def test_audit_reason_partitions_the_delta():
|
||||
delta = ["a", "b", "c"]
|
||||
j = _sig("J", ["b", "c", "x", "y"]) # explains b,c; misses a; reaches beyond into x,y
|
||||
r = match_journeys(delta, [j]).matches[0].reason
|
||||
assert r.matched_capabilities == ["b", "c"]
|
||||
assert r.unexplained_delta == ["a"]
|
||||
assert r.journey_only == ["x", "y"]
|
||||
|
||||
|
||||
def test_context_corroborates_but_never_changes_score():
|
||||
delta = ["a", "b"]
|
||||
same = _sig("same", ["a", "b"], target_type="regulation")
|
||||
other = _sig("other", ["a", "b"], target_type="contract")
|
||||
ctx = MatchContext(target_type="regulation")
|
||||
res = match_journeys(delta, [other, same], ctx)
|
||||
# identical score (1.0) -> tie broken by context-signal count: 'same' first
|
||||
assert res.matches[0].score == res.matches[1].score == 1.0
|
||||
assert res.matches[0].journey_id == "same"
|
||||
assert "gleiche Zielart" in res.matches[0].reason.context_signals
|
||||
assert res.matches[1].reason.context_signals == []
|
||||
|
||||
|
||||
def test_deterministic_tiebreak_by_journey_id():
|
||||
delta = ["a", "b"]
|
||||
res = match_journeys(delta, [_sig("zeta", ["a"]), _sig("alpha", ["a"])])
|
||||
assert [m.journey_id for m in res.matches] == ["alpha", "zeta"]
|
||||
|
||||
|
||||
def test_no_journey_explains_the_delta():
|
||||
res = match_journeys(["a", "b"], [_sig("J", ["x", "y"])])
|
||||
assert res.best is None
|
||||
assert res.matches[0].score == 0.0
|
||||
assert "neue Journey-Kandidatin" in res.headline
|
||||
|
||||
|
||||
def test_empty_delta_yields_no_best():
|
||||
res = match_journeys([], [_sig("J", ["a"])])
|
||||
assert res.delta_size == 0
|
||||
assert res.best is None
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Journey Matcher demo test — Delta -> Journey on the real transition patterns.
|
||||
|
||||
Pins that the matcher, given ONLY a real Capability Delta (a multi-cert company wanting CRA +
|
||||
MaschinenVO), correctly ranks the known journeys by explanatory power: the convergence journey
|
||||
explains the whole delta, the CRA-only journey explains the security part but misses the machine-
|
||||
safety capabilities, and the TISAX journey is irrelevant. End-to-end through the real engines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _run():
|
||||
root = os.path.join(os.path.dirname(__file__), "..")
|
||||
r = subprocess.run(
|
||||
[sys.executable, "reference_scenarios/journey_matcher_demo.py"],
|
||||
cwd=root, env={**os.environ, "PYTHONPATH": "."}, capture_output=True, text=True,
|
||||
)
|
||||
assert r.returncode == 0, r.stderr
|
||||
return r.stdout
|
||||
|
||||
|
||||
def test_runs_end_to_end():
|
||||
out = _run()
|
||||
assert "Journey Matcher" in out
|
||||
assert "Goal → Required → Delta → Journey" in out
|
||||
|
||||
|
||||
def test_convergence_journey_explains_the_whole_delta():
|
||||
out = _run()
|
||||
assert "**ISO27001 -> CRA + MaschinenVO** | 9 von 9 fehlenden Capabilities | 100% |" in out
|
||||
|
||||
|
||||
def test_partial_journey_misses_machine_safety():
|
||||
out = _run()
|
||||
# CRA-only journey explains the security part but not the MaschinenVO capabilities
|
||||
assert "**ISO27001 -> CRA** | 5 von 9 fehlenden Capabilities | 56% |" in out
|
||||
|
||||
|
||||
def test_irrelevant_journey_scores_zero():
|
||||
out = _run()
|
||||
assert "**ISMS -> TISAX** | 0 von 9 fehlenden Capabilities | 0% |" in out
|
||||
|
||||
|
||||
def test_match_is_auditable():
|
||||
out = _run()
|
||||
assert "auditierbar, keine Blackbox" in out
|
||||
assert "Erklärte Capabilities" in out
|
||||
@@ -0,0 +1,54 @@
|
||||
# ADR-011: Journey Matcher — the delta explains the journey (Delta -> Journey)
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Datum:** 2026-06-28
|
||||
- **Typ:** Architektur-Entscheidung (neues Modul — vom User ausdrücklich freigegeben)
|
||||
- **Bezug:** [ADR-003](ADR-003-capability-delta-engine-with-renderers.md), [ADR-002](ADR-002-transition-is-data-not-architecture.md), [[transition-reasoning]], [[strategy-requirements-intelligence]], journey-model-spec-v1
|
||||
|
||||
## Kontext
|
||||
|
||||
Bisher war die **Journey die Ursache**: der Berater wählte vorne eine Journey (`Goal → Journey → Delta`).
|
||||
Genau das war der eine offene „Sprung" aus Customer Mission #1 (Scope → Journey). Wir haben ihn bewusst
|
||||
NICHT zu früh gebaut, sondern erst **fünf bewusst unterschiedliche Zielarten** validiert (Mission #1–#5:
|
||||
Regulation · Certification · Public Tender · OEM-Spec · Umwelt/Material). Damit existiert die Diversität,
|
||||
um aus **beobachteten Fällen** zu generalisieren statt aus Annahmen — die Voraussetzung, den Selektor
|
||||
überhaupt sinnvoll zu bauen.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
1. **Umkehrung der Reihenfolge: `Goal → Required → Delta → Journey`.** Die Journey ist die **Erklärung**
|
||||
des Capability Deltas, nicht seine Ursache. Genannt **Journey Matcher / Explainer**, nicht „Selector".
|
||||
|
||||
2. **Neues Modul `compliance/journey_matcher/` = die dritte Funktion `Delta → Journey`** — neben
|
||||
Company 2A (`Evidence → Capability`) und RS-005 (`Capability → Delta`). Drei **unabhängige,
|
||||
austauschbare** Funktionen, drei verschiedene Probleme.
|
||||
|
||||
3. **Der Matcher sieht NUR das Capability Delta** — keine Zertifikate, kein Regelwerk, kein Ziel.
|
||||
Journey-Signaturen sind **zertifikat-agnostische Capability-Cluster** (`Input Pattern → Output Pattern`);
|
||||
die IDs wie „ISO27001 → CRA" sind nur eine Beschreibung der Cluster, der Matcher liest sie nie.
|
||||
**Score = Anteil des Deltas, den eine Journey erklärt** (Recall über die fehlenden Capabilities), nie
|
||||
ein „Fit" oder ein Konformitätsurteil. `journey_only` dokumentiert, wo eine Journey über das Delta
|
||||
hinausreicht (eine breite Journey wird nicht still bevorzugt).
|
||||
|
||||
4. **Bewusst dumm + deterministisch:** reine Mengenüberlappung, **kein ML, keine Embeddings, kein LLM**.
|
||||
Voll auditierbar (matched / unexplained / journey_only / Kontext-Signale). Ein lernendes Ranking kann
|
||||
später DAVOR gesetzt werden; der deterministische Kern bleibt nachvollziehbar. Kontext (Branche/
|
||||
Produkttyp/Zielart) ist nur **dokumentierte Korroboration + Tie-Break**, nie Teil des Scores.
|
||||
|
||||
5. **Signaturen werden INJIZIERT**, nicht im Kern geladen — die Engine bleibt hermetisch (wie RS-005).
|
||||
|
||||
## Konsequenzen
|
||||
|
||||
- **Der „Scope → Journey"-Sprung aus Mission #1 ist aufgelöst:** es gibt keinen Journey-Matcher als
|
||||
Sonderlogik je Zielart — die Journey ergibt sich aus dem Delta. An echten Pattern validiert: ein
|
||||
CRA+MaschinenVO-Delta rankt die Konvergenz-Journey 100 %, „ISO27001 → CRA" 56 % (verfehlt die
|
||||
Maschinensicherheit), „ISMS → TISAX" 0 %.
|
||||
- **Letzter wirklich architektureller Baustein.** Alles danach ist überwiegend **Wissensarbeit,
|
||||
Korpusaufbau, Domänenmodellierung** — in der Pipeline `Reality → Evidence → Capability Profile →
|
||||
Required State → Capability Delta → Journey → Roadmap → Playbooks → Verification` kommt „Regulation"
|
||||
nicht mehr als Sonderfall vor.
|
||||
- **Freeze-Ausnahme, bewusst:** der User hat dieses EINE neue Modul ausdrücklich freigegeben. Kein
|
||||
weiteres Metamodell/Graph. Non-runtime (kein App-Caller) → kein Deploy ([ADR-001](ADR-001-runtime-deploy-policy.md)).
|
||||
- **Folgearbeit (nicht jetzt):** Journeys als reine `Capability Cluster A → Cluster B` (statt ISO/CRA-IDs);
|
||||
`Intent → Scope → Journey`-Ebene darüber; lernendes Ranking als Vor-Stufe; `relevance(evidence, target)`
|
||||
als eigene Berechnung (aus Mission #5).
|
||||
Reference in New Issue
Block a user