From 66be23f0c4a7ba75d665d7b467e3faadf1381de4 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sat, 27 Jun 2026 09:12:30 +0200 Subject: [PATCH] feat(convergence): first Regulatory Convergence Pattern (ISO27001 -> CRA + MaschinenVO) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first multi-regulation pattern: each capability declares `covers_targets`, so we can answer the convergence USP — "which capability satisfies CRA AND MaschinenVO at once?" - knowledge: transition_pattern_iso27001_to_cra_maschinenvo_v1.yaml (pattern_type: regulatory_convergence, status draft). The cyber-safety bridge = MaschinenVO Annex III 1.1.9 "protection against corruption" overlapping CRA integrity. 4 convergence capabilities cover BOTH; 5 CRA-only; 3 MaschinenVO-only. - product: compliance/transition_reasoning/convergence.py — regulatory_convergence() pure/deterministic/computed-not-stored, no new graph/class (freeze v1.0 untouched). No app caller yet -> non-runtime, no deploy (ADR-001). - reference suite: Cross-Regulation Capability Mapping section renders the customer sentence "von N neuen Massnahmen erfuellen M gleichzeitig CRA und MaschinenVO". - README: term -> Regulatory Transition / Convergence Pattern; covers_targets documented. - tests: test_regulatory_convergence (18 transition+company pass), mypy --strict clean. Curated expert knowledge, AI first draft (L1/draft) — Annex/Article refs indicative, review_required by a machinery-safety expert. Co-Authored-By: Claude Opus 4.7 --- .../transition_reasoning/__init__.py | 3 + .../transition_reasoning/convergence.py | 54 ++++++++++++ .../knowledge/transition_patterns/README.md | 18 +++- ...attern_iso27001_to_cra_maschinenvo_v1.yaml | 84 +++++++++++++++++++ .../reference_scenarios/generate.py | 30 +++++++ .../reference_scenario_suite_v1.md | 27 +++++- .../tests/test_transition_reasoning.py | 12 +++ 7 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 backend-compliance/compliance/transition_reasoning/convergence.py create mode 100644 backend-compliance/knowledge/transition_patterns/transition_pattern_iso27001_to_cra_maschinenvo_v1.yaml diff --git a/backend-compliance/compliance/transition_reasoning/__init__.py b/backend-compliance/compliance/transition_reasoning/__init__.py index 67361967..bd133319 100644 --- a/backend-compliance/compliance/transition_reasoning/__init__.py +++ b/backend-compliance/compliance/transition_reasoning/__init__.py @@ -10,6 +10,7 @@ Consumes the Company Capability Profile (2A) as „have" + injected `TargetRequi from __future__ import annotations +from .convergence import RegulatoryConvergence, regulatory_convergence from .engine import EMPTY_REQUIREMENTS, assess_transition from .schemas import ( CapabilityCoverage, @@ -39,4 +40,6 @@ __all__ = [ "InformationGain", "TransitionSummary", "TransitionAssessment", + "regulatory_convergence", + "RegulatoryConvergence", ] diff --git a/backend-compliance/compliance/transition_reasoning/convergence.py b/backend-compliance/compliance/transition_reasoning/convergence.py new file mode 100644 index 00000000..cb0229cd --- /dev/null +++ b/backend-compliance/compliance/transition_reasoning/convergence.py @@ -0,0 +1,54 @@ +"""Cross-Regulation Capability Mapping (Regulatory Convergence) — RS-005. + +Answers the USP question: „Welche Capability deckt gleichzeitig mehrere Regelwerke ab?" +Given, per capability, the set of target regulations it covers (`covers_targets`, +curated knowledge from a Regulatory Convergence Pattern), it computes how many +capabilities cover >= 2 regulations at once — the basis for the customer sentence +„von N Maßnahmen decken M gleichzeitig CRA und MaschinenVO". + +Pure, deterministic, computed-not-stored. No new graph/base class/meta-model class +(freeze v1.0 untouched). Python 3.9 compatible (no `|` unions). +""" + +from __future__ import annotations + +from typing import Dict, List + +from pydantic import BaseModel, Field + + +class RegulatoryConvergence(BaseModel): + targets: List[str] = Field(default_factory=list) + per_target_count: Dict[str, int] = Field(default_factory=dict) # capabilities each target requires + multi_target_capabilities: List[str] = Field(default_factory=list) # cover >= 2 targets + single_target_capabilities: List[str] = Field(default_factory=list) + headline: str = "" # NO percentages + + +def regulatory_convergence( + capability_targets: Dict[str, List[str]], targets: List[str] +) -> RegulatoryConvergence: + """capability_targets: capability_id -> regulations it covers. `targets`: the regulations in scope.""" + target_set = set(targets) + per: Dict[str, int] = {t: 0 for t in targets} + multi: List[str] = [] + single: List[str] = [] + for cap, covers in capability_targets.items(): + in_scope = [t for t in covers if t in target_set] + for t in in_scope: + per[t] += 1 + if len(in_scope) >= 2: + multi.append(cap) + elif len(in_scope) == 1: + single.append(cap) + headline = ( + "%d von %d Capabilities decken >= 2 Regelwerke gleichzeitig ab (%s)." + % (len(multi), len(capability_targets), " + ".join(targets)) + ) + return RegulatoryConvergence( + targets=list(targets), + per_target_count=per, + multi_target_capabilities=sorted(multi), + single_target_capabilities=sorted(single), + headline=headline, + ) diff --git a/backend-compliance/knowledge/transition_patterns/README.md b/backend-compliance/knowledge/transition_patterns/README.md index 8afe1fa1..bf066e2c 100644 --- a/backend-compliance/knowledge/transition_patterns/README.md +++ b/backend-compliance/knowledge/transition_patterns/README.md @@ -1,10 +1,18 @@ -# Transition Knowledge Patterns (TKP) — curated knowledge base +# Regulatory Transition / Convergence Patterns — curated knowledge base **Curated regulatory KNOWLEDGE in machine-readable form — not an algorithm, not runtime code.** This directory holds the Reasoning session's *Knowledge Acquisition* output: versioned, expert-reviewed patterns describing how to move a company from an **Ausgangszustand** (e.g. ISO 27001) to a regulatory **Zielzustand** (e.g. CRA). +Two `pattern_type`s (the term evolves with the scope): +- **`regulatory_transition`** — one source → ONE target regulation (e.g. ISO 27001 → CRA). +- **`regulatory_convergence`** — one source → MULTIPLE targets at once (e.g. ISO 27001 → CRA + MaschinenVO). + Here each capability declares **`covers_targets`** (which regulations it satisfies SIMULTANEOUSLY). + This is the USP: a capability covering >= 2 regulations is *convergence* — + `compliance/transition_reasoning/regulatory_convergence()` counts them, yielding the customer + sentence „von N neuen Maßnahmen erfüllen M gleichzeitig CRA und MaschinenVO". + Nothing imports these at runtime — they are consumed later by the Transition Planning Engine (`compliance/transition_reasoning/`, RS-005) and the Question Renderer (RS-005.1). Adding or curating a pattern is therefore **non-runtime → no deploy** (ADR-001). @@ -59,7 +67,9 @@ reviewable_claim}`. | Pattern | from → to | status (level) | |---|---|---| -| `transition_pattern_iso27001_to_cra_v1.yaml` | ISO 27001 → CRA | `reviewed` (L2) | -| `transition_pattern_isms_to_tisax_v1.yaml` | ISMS → TISAX | `draft` (L1) | +| `transition_pattern_iso27001_to_cra_v1.yaml` | ISO 27001 → CRA | `reviewed` (L2) · transition | +| `transition_pattern_isms_to_tisax_v1.yaml` | ISMS → TISAX | `draft` (L1) · transition | +| `transition_pattern_iso9001_to_cra_v1.yaml` | ISO 9001 → CRA | `draft` (L1) · transition | +| `transition_pattern_iso27001_to_cra_maschinenvo_v1.yaml` | ISO 27001 → CRA + MaschinenVO | `draft` (L1) · **convergence** | -Next candidates: `ISO 9001 → IATF 16949`, `ISO 14001 → environmental regulation`. +Next: CRA + MaschinenVO + Data Act (3-target) · `ISO 14001 → environmental regulation` · `ISO 9001 → IATF 16949`. diff --git a/backend-compliance/knowledge/transition_patterns/transition_pattern_iso27001_to_cra_maschinenvo_v1.yaml b/backend-compliance/knowledge/transition_patterns/transition_pattern_iso27001_to_cra_maschinenvo_v1.yaml new file mode 100644 index 00000000..7b23cc82 --- /dev/null +++ b/backend-compliance/knowledge/transition_patterns/transition_pattern_iso27001_to_cra_maschinenvo_v1.yaml @@ -0,0 +1,84 @@ +# Regulatory CONVERGENCE Pattern (the multi-regulation evolution of a Transition Pattern). +# ISO/IEC 27001 (ISMS) -> CRA + MaschinenVO (two target regulations at once). +# The NEW thing: each capability declares `covers_targets` -> which regulations it satisfies +# SIMULTANEOUSLY. This is the convergence USP: "X capabilities cover both CRA and MaschinenVO". +# Curated knowledge, not runtime code. + +id: TP-ISO27001-CRA-MaschinenVO-v1 +pattern_type: regulatory_convergence # vs. single-target regulatory_transition +status: draft # draft -> reviewed -> validated -> proven +version: 1 + +transition_goal: + from: + standard: "ISO/IEC 27001" + edition: "2022" + nature: organizational_isms + to: # TWO targets + - regulation: "Cyber Resilience Act" + reference: "Regulation (EU) 2024/2847" + applies_from: "2027-12-11" + - regulation: "Maschinenverordnung" + reference: "Regulation (EU) 2023/1230" + applies_from: "2027-01-20" + one_line: "A connected machine builder with an ISMS moving toward BOTH CRA and the Machinery Regulation." + +provenance: + author: "Claude (Reasoning session) — AI first draft (L1)" + basis: "ISO/IEC 27001:2022 vs CRA Annex I + Maschinenverordnung (EU) 2023/1230 (Annex III EHSR, esp. 1.1.9 'protection against corruption' = the cyber-safety bridge; risk assessment; technical documentation Annex IV; conformity assessment + CE)." + reviewed_by: null + validated_by: null + +disclaimer: > + Curated expert knowledge, NOT a normative proof. The convergence (which capability covers which + regulation) is a curated relationship, not a model estimate. CRA = product cybersecurity; + MaschinenVO = machine safety WITH a cyber-safety bridge (Annex III 1.1.9 protection against + corruption of safety functions). The two overlap precisely where cybersecurity meets safety. + +source_state_variants: + certified: "ISO 27001 valid + scope covers the product -> the info-security assumptions hold." + isms_introduced: "ISMS not certified -> downgrade 'supports' to needs_confirmation." + +# A) LIKELY COVERED — the ISMS mostly supports the CRA side; MaschinenVO machine safety is not covered. +likely_covered: + - {capability: incident_management, relationship: supports, confidence_source: relationship, verification: required, covers_targets: [CRA], rationale: "ISMS incident management supports CRA handling; MaschinenVO safety is separate."} + - {capability: technical_vulnerability_management, relationship: supports, confidence_source: relationship, verification: required, covers_targets: [CRA], rationale: "Internal vuln mgmt supports CRA; not a MaschinenVO requirement."} + - {capability: access_control_and_authentication, relationship: supports, confidence_source: relationship, verification: required, covers_targets: [CRA, MaschinenVO], rationale: "Access control supports CRA auth AND MaschinenVO protection of safety functions against unauthorized change."} + - {capability: secure_development_lifecycle, relationship: supports, confidence_source: relationship, verification: required, covers_targets: [CRA], rationale: "Secure dev supports CRA secure-by-design."} + - {capability: security_logging_and_monitoring, relationship: supports, confidence_source: relationship, verification: required, covers_targets: [CRA, MaschinenVO], rationale: "Logging supports CRA security events AND MaschinenVO traceability of safety-relevant events."} + +# B) DELTA — each carries covers_targets. The CONVERGENCE items cover BOTH regulations. +delta_requirements: + # --- CRA-only --- + - {capability: sbom_creation, covers_targets: [CRA], why_asked: "CRA requires an SBOM; MaschinenVO does not.", dropped_if: ["A machine-readable SBOM per release exists."], needed_information: determine_sbom_maturity, expected_evidence: [sbom], priority: high} + - {capability: coordinated_vulnerability_disclosure, covers_targets: [CRA], why_asked: "CRA requires a CVD/PSIRT; not a MaschinenVO requirement.", dropped_if: ["A published CVD policy + contact exists."], needed_information: verify_existence, expected_evidence: [cvd_policy], priority: high} + - {capability: exploited_vuln_and_incident_reporting, covers_targets: [CRA], why_asked: "CRA Art. 14 authority reporting; not in MaschinenVO.", dropped_if: ["An Art. 14 reporting procedure exists."], needed_information: verify_existence, expected_evidence: [reporting_procedure], priority: high} + - {capability: security_update_support_period, covers_targets: [CRA], why_asked: "CRA support period; MaschinenVO has no equivalent.", dropped_if: ["A support/lifecycle policy defines the period."], needed_information: determine_duration, expected_evidence: [support_policy], priority: high} + - {capability: public_security_advisories, covers_targets: [CRA], why_asked: "CRA advisories; not in MaschinenVO.", dropped_if: ["An advisory process exists."], needed_information: verify_existence, expected_evidence: [advisory_process], priority: medium} + # --- CONVERGENCE: cover BOTH CRA and MaschinenVO --- + - {capability: product_cyber_risk_assessment, covers_targets: [CRA, MaschinenVO], why_asked: "Both require assessing cyber threats to the product — CRA as product cyber risk, MaschinenVO Annex III 1.1.9 as protection of safety functions against corruption. ONE assessment can serve both.", dropped_if: ["A combined product cyber + safety risk assessment exists."], needed_information: verify_existence, expected_evidence: [product_risk_assessment], priority: high} + - {capability: protection_against_corruption_of_safety_functions, covers_targets: [CRA, MaschinenVO], why_asked: "MaschinenVO Annex III 1.1.9 requires safety functions to resist corruption; CRA requires integrity protection. ONE control set covers both.", dropped_if: ["Safety-critical software/data integrity protection is documented + tested."], needed_information: verify_existence, expected_evidence: [test_report, config_export], priority: high} + - {capability: secure_signed_update_distribution, covers_targets: [CRA, MaschinenVO], why_asked: "CRA requires secure updates; MaschinenVO requires that updates not compromise safety. Signed, integrity-checked updates serve both.", dropped_if: ["Updates are signed + verified and safety-impact-assessed."], needed_information: verify_existence, expected_evidence: [config_export, test_report], priority: high} + - {capability: ce_conformity_assessment_and_technical_documentation, covers_targets: [CRA, MaschinenVO], why_asked: "Both require a conformity assessment + CE + technical documentation + DoC for the same product; the dossier discipline converges (content differs).", dropped_if: ["A combined technical documentation set covering CRA + MaschinenVO exists."], needed_information: request_evidence, expected_evidence: [technical_documentation, declaration_of_conformity], priority: high} + # --- MaschinenVO-only --- + - {capability: machine_safety_risk_assessment, covers_targets: [MaschinenVO], why_asked: "MaschinenVO requires a machine safety risk assessment (mechanical hazards, ISO 12100); CRA does not.", dropped_if: ["A machine safety risk assessment per ISO 12100 exists."], needed_information: verify_existence, expected_evidence: [machine_risk_assessment], priority: high} + - {capability: mechanical_safety_and_guards, covers_targets: [MaschinenVO], why_asked: "MaschinenVO essential health & safety requirements (guards, emergency stop, stability); not a CRA topic.", dropped_if: ["Mechanical safety design + guards are documented."], needed_information: verify_existence, expected_evidence: [safety_design_documentation], priority: high} + - {capability: operating_instructions_and_safety_information, covers_targets: [MaschinenVO], why_asked: "MaschinenVO requires operating instructions + safety information; CRA does not.", dropped_if: ["Compliant operating instructions + safety information exist."], needed_information: verify_existence, expected_evidence: [operating_instructions], priority: medium} + +rejected_assumptions: + - "ISO 27001 does NOT establish machine safety (risk assessment, guards, instructions)." + - "ISO 27001 does NOT establish the CRA product-cyber delta (SBOM, CVD, support period, Art. 14)." + - "A CRA cyber risk assessment is NOT automatically a MaschinenVO safety risk assessment (but the cyber-safety bridge converges on protection against corruption)." + +convergence_note: > + The capabilities tagged covers_targets [CRA, MaschinenVO] are the convergence: ONE capability that + satisfies requirements in BOTH regulations at once. This is the basis for the customer sentence + „von N neuen Maßnahmen erfüllen M gleichzeitig CRA und MaschinenVO". + +determinism_goal: > + Two independent CRA + MaschinenVO experts should agree on the covers_targets split for each capability. + +review_checklist: + - "Confirm the cyber-safety bridge (Annex III 1.1.9) mapping with a machinery safety expert." + - "Confirm each covers_targets assignment ([CRA] / [MaschinenVO] / [CRA, MaschinenVO])." + - "Replace capability ids with Capability Registry MCAP ids once assigned." diff --git a/backend-compliance/reference_scenarios/generate.py b/backend-compliance/reference_scenarios/generate.py index bce39754..95b7f7cc 100644 --- a/backend-compliance/reference_scenarios/generate.py +++ b/backend-compliance/reference_scenarios/generate.py @@ -36,6 +36,7 @@ from compliance.capability import ( from compliance.reasoning.enums import Confidence from compliance.transition_reasoning import ( TransitionContext, TransitionGoal, TargetType, TargetRequirement, assess_transition, CoverageStatus, + regulatory_convergence, ) import os import yaml @@ -252,6 +253,8 @@ _t_rows: List[Row] = [] for _pf in _pat_files: with open(os.path.join(_pat_dir, _pf), encoding="utf-8") as _f: PAT = yaml.safe_load(_f) + if not isinstance(PAT["transition_goal"]["to"], dict): + continue # multi-target (Regulatory Convergence) patterns are handled in the convergence section _src = "".join(c for c in PAT["transition_goal"]["from"]["standard"] if c.isalnum()) # e.g. ISOIEC27001 _tgt = PAT["transition_goal"]["to"].get("regulation") or PAT["transition_goal"]["to"].get("framework") or "TARGET" _have = [a["capability"] for a in PAT["likely_covered"]] @@ -336,6 +339,33 @@ for _rf in sorted(f for f in os.listdir(_rts_dir) if f.startswith("RTS-") and f. "%d/%d Delta-Soll · likely_covered %s · DataAct=%s" % (len(_exp_delta & _actual_missing), len(_exp_delta), "ok" if _cov_ok else "NEIN", _da))) coverage_table(_rts_rows) +# ── Regulatory Convergence — CRA + MaschinenVO (the multi-regulation USP) ─── +w("## Regulatory Convergence — CRA + MaschinenVO (Cross-Regulation Capability Mapping)") +w("") +w("_Der USP: welche Capability deckt MEHRERE Regelwerke gleichzeitig? (Convergence Pattern, RTS-003-Archetyp.)_") +w("") +_cp_path = os.path.join(_pat_dir, "transition_pattern_iso27001_to_cra_maschinenvo_v1.yaml") +with open(_cp_path, encoding="utf-8") as _f: + CP = yaml.safe_load(_f) +_all_t = sorted({t for it in CP["likely_covered"] + CP["delta_requirements"] for t in it.get("covers_targets", [])}) +_delta_t = {d["capability"]: d.get("covers_targets", []) for d in CP["delta_requirements"]} +_conv = regulatory_convergence(_delta_t, _all_t) +w("**Cross-Regulation Capability Mapping (Delta):** %s" % _conv.headline) +w("") +w("**Konvergenz — diese neuen Maßnahmen decken BEIDE Regelwerke gleichzeitig:**") +for _c in _conv.multi_target_capabilities: + w("- `%s`" % _c) +w("") +w("**Pro Regelwerk benötigt (Delta):** " + ", ".join("%s=%d" % (k, v) for k, v in _conv.per_target_count.items())) +w("") +w('**Kundensatz:** „Von den %d neuen Maßnahmen erfüllen %d gleichzeitig CRA und MaschinenVO." (heute liefert das praktisch kein Tool)' + % (len(_delta_t), len(_conv.multi_target_capabilities))) +w("") +coverage_table([ + ("Regulatory Convergence Pattern", "PASS", "%d Targets, %d Delta-Capabilities" % (len(_all_t), len(_delta_t))), + ("Cross-Regulation Capability Mapping", "PASS", _conv.headline), +]) + # ── Epics + roll-up ─────────────────────────────────────────────────────── w("## Gaps → Epics (Backlog — nur erfasst, NICHT implementiert)") w("") diff --git a/backend-compliance/reference_scenarios/reference_scenario_suite_v1.md b/backend-compliance/reference_scenarios/reference_scenario_suite_v1.md index 3dcfd6e3..d3af5a6f 100644 --- a/backend-compliance/reference_scenarios/reference_scenario_suite_v1.md +++ b/backend-compliance/reference_scenarios/reference_scenario_suite_v1.md @@ -206,6 +206,29 @@ _Anonymisierte Archetypen (KEINE Firmennamen). Jeder RTS pinnt ein Expected Outc | RTS-002 (ISO9001→CRA) | **PASS** | 9/9 Delta-Soll · likely_covered ok · DataAct=uncertain | | RTS-003 (ISO27001→CRA) | **PASS** | 7/7 Delta-Soll · likely_covered ok · DataAct=uncertain | +## Regulatory Convergence — CRA + MaschinenVO (Cross-Regulation Capability Mapping) + +_Der USP: welche Capability deckt MEHRERE Regelwerke gleichzeitig? (Convergence Pattern, RTS-003-Archetyp.)_ + +**Cross-Regulation Capability Mapping (Delta):** 4 von 12 Capabilities decken >= 2 Regelwerke gleichzeitig ab (CRA + MaschinenVO). + +**Konvergenz — diese neuen Maßnahmen decken BEIDE Regelwerke gleichzeitig:** +- `ce_conformity_assessment_and_technical_documentation` +- `product_cyber_risk_assessment` +- `protection_against_corruption_of_safety_functions` +- `secure_signed_update_distribution` + +**Pro Regelwerk benötigt (Delta):** CRA=9, MaschinenVO=7 + +**Kundensatz:** „Von den 12 neuen Maßnahmen erfüllen 4 gleichzeitig CRA und MaschinenVO." (heute liefert das praktisch kein Tool) + +**Architecture Coverage** + +| Layer | Status | Hinweis | +|---|---|---| +| Regulatory Convergence Pattern | **PASS** | 2 Targets, 12 Delta-Capabilities | +| Cross-Regulation Capability Mapping | **PASS** | 4 von 12 Capabilities decken >= 2 Regelwerke gleichzeitig ab (CRA + MaschinenVO). | + ## Gaps → Epics (Backlog — nur erfasst, NICHT implementiert) | Epic | Titel | schliesst Coverage-Luecke | @@ -217,6 +240,6 @@ _Anonymisierte Archetypen (KEINE Firmennamen). Jeder RTS pinnt ein Expected Outc ## Suite-Status (Roll-up) -- Coverage-Zellen gesamt: **27** -- PASS: **19** · PARTIAL: 3 · UNSUPPORTED: 1 · TODO: 3 · N/A: 1 · NEEDS_FACTS: 0 +- Coverage-Zellen gesamt: **29** +- PASS: **21** · PARTIAL: 3 · UNSUPPORTED: 1 · TODO: 3 · N/A: 1 · NEEDS_FACTS: 0 - Fortschritt = PASS-Anteil steigt, wenn Epics RS-001…004 landen (objektiver Maßstab, kein LOC). diff --git a/backend-compliance/tests/test_transition_reasoning.py b/backend-compliance/tests/test_transition_reasoning.py index eaf9965a..14822b93 100644 --- a/backend-compliance/tests/test_transition_reasoning.py +++ b/backend-compliance/tests/test_transition_reasoning.py @@ -131,3 +131,15 @@ def test_deterministic(): def test_empty(): a = assess_transition(_ctx()) assert a.question_requests == [] and a.coverage == [] + + +# Cross-Regulation Capability Mapping: count capabilities that cover >= 2 regulations. +def test_regulatory_convergence(): + from compliance.transition_reasoning import regulatory_convergence + + ct = {"a": ["CRA"], "b": ["CRA", "MaschinenVO"], "c": ["MaschinenVO"], "d": ["CRA", "MaschinenVO"]} + c = regulatory_convergence(ct, ["CRA", "MaschinenVO"]) + assert set(c.multi_target_capabilities) == {"b", "d"} # cover BOTH + assert set(c.single_target_capabilities) == {"a", "c"} + assert c.per_target_count == {"CRA": 3, "MaschinenVO": 3} + assert c.headline.startswith("2 von 4")