feat(transition): Transition Reasoning v0 (RS-005) — Transition Planning Engine

Second reasoning mode, scope per user: the engine owns the INFORMATION GAPS, not the
questions. assess_transition(context, target_requirements, company_profile) emits
ranked TransitionQuestionRequest {capability, control, reason, question_intent,
expected_evidence, priority, information_gain} -- NOT rendered question text. Rendering
(intent+subject->sentence) is a separate swappable layer (RS-005.1), not here.

Consumes the Company Capability Profile (2A) as "have" + injected TargetRequirement
(Execution-owned placeholder) as "required" -- no required-capability data in product
code (EMPTY_REQUIREMENTS, mocks only in tests). A certification-derived capability is
probably_covered (Welt 1) -> a confirmation request, never already_covered/"erfuellt".
Deterministic, computed-not-stored, no percentages.

Activates 2A/2C/RCI (first consumer of the Company profile). Freeze-respecting: additive
package, no new graph/base class/meta-model class. 9 tests, mypy --strict clean, LOC ok.
No endpoint/UI/RAG; question rendering deliberately deferred to RS-005.1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-27 07:31:11 +02:00
parent 5e735e9e56
commit 77de7e794c
4 changed files with 423 additions and 0 deletions
@@ -0,0 +1,42 @@
"""Transition Reasoning v0 (RS-005) — the Transition Planning Engine.
Answers „Was muss ich noch wissen, um vom Ausgangs- in den regulatorischen
Zielzustand zu kommen?". Owns the **information gaps** (`TransitionQuestionRequest`),
NOT the rendered questions (rendering = separate RS-005.1 layer).
Consumes the Company Capability Profile (2A) as „have" + injected `TargetRequirement`
(Execution-owned placeholder) as „required". Spec: docs-src/architecture/transition-reasoning-spec-v1.md.
"""
from __future__ import annotations
from .engine import EMPTY_REQUIREMENTS, assess_transition
from .schemas import (
CapabilityCoverage,
CoverageStatus,
InformationGain,
RequestPriority,
TargetRequirement,
TargetType,
TransitionAssessment,
TransitionContext,
TransitionGoal,
TransitionQuestionRequest,
TransitionSummary,
)
__all__ = [
"assess_transition",
"EMPTY_REQUIREMENTS",
"TransitionContext",
"TransitionGoal",
"TargetType",
"TargetRequirement",
"TransitionQuestionRequest",
"CapabilityCoverage",
"CoverageStatus",
"RequestPriority",
"InformationGain",
"TransitionSummary",
"TransitionAssessment",
]
@@ -0,0 +1,138 @@
"""Transition Reasoning v0 (RS-005) — the Transition Planning Engine.
`assess_transition(context, target_requirements, company_profile)`: computes, per
required capability, the coverage from the company's „have" state (Phase 2A), and
emits a ranked list of `TransitionQuestionRequest` (information gaps) — NOT questions.
Deterministic; nothing is stored. A certification-derived „probably covered" is Welt 1
(a hint), so it produces a confirmation request, never „erfüllt". Python 3.9 compatible.
"""
from __future__ import annotations
from typing import Dict, List, Optional
from compliance.company import CompanyCapabilityProfile, VerificationStatus
from .schemas import (
CapabilityCoverage,
CoverageStatus,
InformationGain,
RequestPriority,
TargetRequirement,
TransitionAssessment,
TransitionContext,
TransitionQuestionRequest,
TransitionSummary,
)
EMPTY_REQUIREMENTS: List[TargetRequirement] = []
_STATUS_RANK = { # strongest „have" signal wins when a capability appears twice
VerificationStatus.CONFIRMED: 3,
VerificationStatus.INFERRED: 2,
VerificationStatus.DECLARED: 1,
VerificationStatus.UNKNOWN: 0,
}
def _have(profile: CompanyCapabilityProfile) -> Dict[str, VerificationStatus]:
out: Dict[str, VerificationStatus] = {}
for oc in profile.confirmed_capabilities:
out[oc.capability_id] = VerificationStatus.CONFIRMED
for c in profile.candidate_capabilities:
cur = out.get(c.capability_id)
if cur is None or _STATUS_RANK[c.verification_status] > _STATUS_RANK[cur]:
out[c.capability_id] = c.verification_status
return out
def _classify(req: TargetRequirement, have: Dict[str, VerificationStatus]) -> CoverageStatus:
if req.unsupported:
return CoverageStatus.UNSUPPORTED
status = have.get(req.capability_id)
if status == VerificationStatus.CONFIRMED:
return CoverageStatus.ALREADY_COVERED
if status == VerificationStatus.INFERRED:
return CoverageStatus.PROBABLY_COVERED
if status == VerificationStatus.DECLARED:
return CoverageStatus.NEEDS_CONFIRMATION
return CoverageStatus.MISSING
# coverage -> (request?, reason, base priority)
_REQUESTABLE = {
CoverageStatus.PROBABLY_COVERED: ("Vermutlich vorhanden (aus Zertifizierung) — mit Nachweis bestätigen.", RequestPriority.MEDIUM),
CoverageStatus.NEEDS_CONFIRMATION: ("Selbst angegeben — Nachweis steht aus.", RequestPriority.MEDIUM),
CoverageStatus.MISSING: ("Keine Anhaltspunkte im Unternehmensprofil — klären.", RequestPriority.HIGH),
}
def _gain(coverage: CoverageStatus, n_obligations: int) -> InformationGain:
base = InformationGain.HIGH if coverage == CoverageStatus.MISSING else (
InformationGain.MEDIUM if coverage == CoverageStatus.NEEDS_CONFIRMATION else InformationGain.LOW
)
if n_obligations >= 2 and base != InformationGain.HIGH: # more dependent obligations -> bump
return InformationGain.HIGH if base == InformationGain.MEDIUM else InformationGain.MEDIUM
return base
_PRIO_ORDER = {RequestPriority.HIGH: 0, RequestPriority.MEDIUM: 1, RequestPriority.LOW: 2}
_GAIN_ORDER = {InformationGain.HIGH: 0, InformationGain.MEDIUM: 1, InformationGain.LOW: 2}
def assess_transition(
context: TransitionContext,
target_requirements: Optional[List[TargetRequirement]] = None,
company_profile: Optional[CompanyCapabilityProfile] = None,
) -> TransitionAssessment:
reqs = EMPTY_REQUIREMENTS if target_requirements is None else target_requirements
have = _have(company_profile) if company_profile is not None else {}
coverage: List[CapabilityCoverage] = []
requests: List[TransitionQuestionRequest] = []
buckets: Dict[CoverageStatus, List[str]] = {s: [] for s in CoverageStatus}
for req in reqs:
status = _classify(req, have)
coverage.append(
CapabilityCoverage(
capability_id=req.capability_id,
status=status,
have_status=have[req.capability_id].value if req.capability_id in have else None,
)
)
buckets[status].append(req.capability_id)
if status in _REQUESTABLE:
reason, prio = _REQUESTABLE[status]
requests.append(
TransitionQuestionRequest(
capability_id=req.capability_id,
control_id=req.source_control_id,
reason=reason,
question_intent=req.question_intent,
expected_evidence=req.expected_evidence,
priority=prio,
information_gain=_gain(status, len(req.supports_obligations)),
)
)
requests.sort(key=lambda r: (_PRIO_ORDER[r.priority], _GAIN_ORDER[r.information_gain], r.capability_id))
summary = TransitionSummary(
headline=(
"%d zu klären, %d bereits abgedeckt, %d vermutlich vorhanden, %d fehlt, %d n/a, %d nicht im Korpus."
% (len(requests), len(buckets[CoverageStatus.ALREADY_COVERED]),
len(buckets[CoverageStatus.PROBABLY_COVERED]), len(buckets[CoverageStatus.MISSING]),
len(buckets[CoverageStatus.NOT_APPLICABLE]), len(buckets[CoverageStatus.UNSUPPORTED]))
),
what_to_clarify=[r.capability_id for r in requests],
already_covered=buckets[CoverageStatus.ALREADY_COVERED],
probably_covered=buckets[CoverageStatus.PROBABLY_COVERED],
missing=buckets[CoverageStatus.MISSING],
not_applicable=buckets[CoverageStatus.NOT_APPLICABLE],
unsupported=buckets[CoverageStatus.UNSUPPORTED],
)
return TransitionAssessment(
target_id=context.target.target_id, coverage=coverage, question_requests=requests, summary=summary
)
@@ -0,0 +1,110 @@
"""Transition Reasoning v0 (RS-005) — domain objects.
The **Transition Planning Engine**: it answers „Was muss ich noch wissen, um vom
Ausgangszustand in den regulatorischen Zielzustand zu kommen?" — NOT „wie frage ich
das?". It therefore owns the **information gaps** (`TransitionQuestionRequest`), never
the rendered question text. Rendering (intent + subject -> sentence) is a separate,
swappable layer (RS-005.1 Question Generator) and is NOT part of this engine.
v0 consumes the Company Capability Profile (Phase 2A) as the „have" state and an
INJECTED list of `TargetRequirement` as the Execution-owned „required" side (no
required-capability data in product code — same discipline as 2A's EMPTY_MAPPING).
Welt-1-Grenze: a probable coverage (from a certification) is a hint, never „erfüllt";
it produces a confirmation request, not a verdict. Application/reasoning types, NOT
meta-model classes (freeze v1.0 untouched). Python 3.9 compatible (no `|` unions).
"""
from __future__ import annotations
from enum import Enum
from typing import List, Optional
from pydantic import BaseModel, Field
class TargetType(str, Enum):
REGULATION = "regulation"
CERTIFICATION = "certification"
FRAMEWORK = "framework"
class CoverageStatus(str, Enum):
ALREADY_COVERED = "already_covered" # confirmed in the company profile
PROBABLY_COVERED = "probably_covered" # inferred (e.g. from a certification)
NEEDS_CONFIRMATION = "needs_confirmation" # only declared / weak signal
MISSING = "missing" # no signal
NOT_APPLICABLE = "not_applicable"
UNSUPPORTED = "unsupported" # domain not yet in the corpus (future_corpus_needed)
class RequestPriority(str, Enum):
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class InformationGain(str, Enum):
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class TransitionGoal(BaseModel):
target_id: str # e.g. "CRA", "TISAX"
target_type: TargetType = TargetType.REGULATION
label: str = ""
class TransitionContext(BaseModel):
company_id: str = ""
known_certifications: List[str] = Field(default_factory=list)
known_regulations: List[str] = Field(default_factory=list)
target: TransitionGoal
# ── INJECTED (Execution-owned): what the target requires ───────────────────
class TargetRequirement(BaseModel):
"""One required capability for the target. In v0 injected; later resolved from
`Obligation -> Control -> Required Capability` + `Control -> question_intent`."""
capability_id: str # MCAP-...
question_intent: str = "verify_existence" # passed through to the request, not rendered
expected_evidence: List[str] = Field(default_factory=list)
source_control_id: Optional[str] = None
supports_obligations: List[str] = Field(default_factory=list)
unsupported: bool = False # domain not yet in the corpus
# ── the OWNED output: an information gap, NOT a question ───────────────────
class TransitionQuestionRequest(BaseModel):
capability_id: str
control_id: Optional[str] = None
reason: str
question_intent: str # verify_existence / determine_duration / ... (rendered later)
expected_evidence: List[str] = Field(default_factory=list)
priority: RequestPriority
information_gain: InformationGain
class CapabilityCoverage(BaseModel):
capability_id: str
status: CoverageStatus
have_status: Optional[str] = None # the 2A VerificationStatus that drove it
class TransitionSummary(BaseModel):
headline: str = "" # counts, NO percentage
what_to_clarify: List[str] = Field(default_factory=list) # capability_ids with a request
already_covered: List[str] = Field(default_factory=list)
probably_covered: List[str] = Field(default_factory=list)
missing: List[str] = Field(default_factory=list)
not_applicable: List[str] = Field(default_factory=list)
unsupported: List[str] = Field(default_factory=list)
class TransitionAssessment(BaseModel):
target_id: str
coverage: List[CapabilityCoverage] = Field(default_factory=list)
question_requests: List[TransitionQuestionRequest] = Field(default_factory=list) # ranked
summary: TransitionSummary