diff --git a/generate/construction_affordances.py b/generate/construction_affordances.py index 8cd253e7..d6c9773e 100644 --- a/generate/construction_affordances.py +++ b/generate/construction_affordances.py @@ -118,13 +118,13 @@ class ConstructionFamily: serving_allowed: bool def __post_init__(self) -> None: - if self.serving_allowed and self.family_id != "proportional_change.decrease_to_fraction": + if self.serving_allowed and self.family_id not in _SERVING_AUTHORIZED_FAMILIES: raise ValueError( f"ConstructionFamily {self.family_id!r}: " - "serving_allowed must be False in this PR — " + "serving_allowed must be False — " "no construction may be promoted to serving without a separate reviewed PR." ) - if not self.diagnostic_only and self.family_id != "proportional_change.decrease_to_fraction": + if not self.diagnostic_only and self.family_id not in _SERVING_AUTHORIZED_FAMILIES: raise ValueError( f"ConstructionFamily {self.family_id!r}: " "diagnostic_only must be True — " @@ -187,15 +187,13 @@ class ConstructionProposal: diagnostic_only: bool = True serving_allowed: bool = False - _VALID_STATUS: ClassVar[Literal["proposed"]] = "proposed" - def __post_init__(self) -> None: - if self.status != self._VALID_STATUS: + if self.status != "proposed": raise ValueError( "ConstructionProposal.status must remain 'proposed'; " "ContractAssessment is the sole runnable/refused authority" ) - if (not self.diagnostic_only or self.serving_allowed) and self.family_id != "proportional_change.decrease_to_fraction": + if (not self.diagnostic_only or self.serving_allowed) and self.family_id not in _SERVING_AUTHORIZED_FAMILIES: raise ValueError( f"ConstructionProposal {self.family_id!r}: " "must be diagnostic_only and serving_allowed=False" @@ -218,6 +216,13 @@ class ConstructionProposal: # Catalog entries # --------------------------------------------------------------------------- +# Families authorized for serving_allowed=True / diagnostic_only=False. +# A new family requires a separate reviewed PR to appear here. +_SERVING_AUTHORIZED_FAMILIES: frozenset[str] = frozenset({ + "proportional_change.decrease_to_fraction", +}) + + _DECREASE_TO_FRACTION_FAMILY = ConstructionFamily( family_id="proportional_change.decrease_to_fraction", display_name="Proportional decrease to fraction", @@ -553,12 +558,14 @@ _BY_RELATION_TYPE: dict[str, ConstructionFamily] = { for family in _CATALOG.values() } -_PROPOSAL_FIRST_FAMILIES: frozenset[str] = frozenset({ - "binding.quantity_entity", - "proportional_change.decrease_to_fraction", - "partition.percent_partition", - "state_change.unary_delta", -}) +# Every catalog family is proposal-first; derived directly from _CATALOG so +# additions need no secondary maintenance. +_PROPOSAL_FIRST_FAMILIES: frozenset[str] = frozenset(_CATALOG) + +# Pre-computed sorted tuple; all_diagnostic_families() returns this constant. +_SORTED_FAMILIES: tuple[ConstructionFamily, ...] = tuple( + _CATALOG[key] for key in sorted(_CATALOG) +) # --------------------------------------------------------------------------- @@ -588,7 +595,7 @@ def lookup_by_relation_type(relation_type: str) -> ConstructionFamily | None: def all_diagnostic_families() -> tuple[ConstructionFamily, ...]: """Return all registered diagnostic families in deterministic (family_id) order.""" - return tuple(_CATALOG[key] for key in sorted(_CATALOG)) + return _SORTED_FAMILIES def propose_construction( @@ -618,37 +625,3 @@ def propose_construction( diagnostic_only=family.diagnostic_only, serving_allowed=family.serving_allowed, ) - - -def make_proposal( - family_id: str, - evidence_spans: tuple[SourceSpan, ...], - assessment_runnable: bool, - missing_roles: tuple[str, ...], - active_hazards: tuple[str, ...], -) -> ConstructionProposal: - """Reject assessment-backed proposal synthesis. - - Every catalog family is proposal-first. The assessment-shaped signature - remains temporarily for callers that need a loud migration failure, but no - ``ConstructionProposal`` may encode assessment output. - - Args: - family_id: Catalog family identifier. - evidence_spans: Source spans from the contract assessment. - assessment_runnable: Whether the corresponding ContractAssessment.runnable - was True. - missing_roles: ContractAssessment.missing_bindings contents. - active_hazards: ContractAssessment.unresolved_hazards contents. - - Raises: - KeyError: If *family_id* is not registered in the catalog. - ValueError: For every catalog family; use ``propose_construction`` - before assessment instead. - """ - _ = (evidence_spans, assessment_runnable, missing_roles, active_hazards) - if family_id not in _CATALOG: - raise KeyError(family_id) - raise ValueError( - f"{family_id} is proposal-first; use propose_construction before assessment" - ) diff --git a/generate/contemplation/pass_manager.py b/generate/contemplation/pass_manager.py index f407ac47..c5c3c9aa 100644 --- a/generate/contemplation/pass_manager.py +++ b/generate/contemplation/pass_manager.py @@ -23,12 +23,11 @@ imports no `evals`, no `generate.derivation`, no `core.reliability_gate`. In v0 answer is the eval lane's domain — a routed R1 setup is `SOLVED_VERIFIED` (admissible, forward-substitutable by construction); R2 is solved + verified end to end here. """ - from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path -from typing import Any, TYPE_CHECKING +from typing import Any, Callable, TYPE_CHECKING from core.comprehension_attempt import ( ComprehensionAttempt, @@ -67,6 +66,63 @@ _UNSUPPORTED_FAMILIES = frozenset( _NOT_MY_DOMAIN = "input_shape" +class InternalContemplationError(RuntimeError): + """Raised when a routing invariant is violated (reader refused after organ was routed). + + This is not a user-visible error and must never be caught as a Refusal. + It indicates a programming error in the route/read contract. + """ + + +# --------------------------------------------------------------------------- +# Organ pipeline descriptor +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class OrganPipeline: + """All organ-specific callables needed by the unified solve/verify path. + + Args: + organ: Stable organ identifier string (e.g. ``"r2_constraints"``). + reader: Callable that parses *text* into a problem struct or Refusal. + solver: Callable that solves the problem struct, returning a numeric + value or Refusal. + reason_adapter: Maps a raw solver refusal reason string to the canonical + family-lookup key. Identity for R2/R3; ``cmb_reason`` for R4. + """ + + organ: str + reader: Callable[[str], Any] + solver: Callable[[Any], Any] + reason_adapter: Callable[[str], str] = field(default=lambda r: r) + + +_PIPELINES: dict[str, OrganPipeline] = { + "r2_constraints": OrganPipeline( + organ="r2_constraints", + reader=read_constraint_problem, + solver=answer_constraint_problem, + ), + "r3_rate": OrganPipeline( + organ="r3_rate", + reader=read_rate_problem, + solver=solve_rate, + ), + "r4_combined_rate": OrganPipeline( + organ="r4_combined_rate", + reader=read_combined_rate_problem, + solver=solve_combined_rate, + reason_adapter=cmb_reason, + ), +} + + +# --------------------------------------------------------------------------- +# Result / Finding helpers +# --------------------------------------------------------------------------- + + @dataclass(frozen=True, slots=True) class ContemplationResult: """The outcome of one bounded contemplation pass.""" @@ -82,12 +138,63 @@ class ContemplationResult: message: str | None = None +def _result( + terminal: Terminal, + findings: list[Finding], + attempts: tuple[ComprehensionAttempt, ...], + /, + **kwargs: Any, +) -> ContemplationResult: + """Append the terminal Finding and return a ContemplationResult in one call. + + Guarantees that the terminal Finding is always the last entry in the + findings sequence — enforced by construction, not by convention. + """ + findings.append(Finding("terminal", terminal.value)) + return ContemplationResult(terminal, tuple(findings), attempts, **kwargs) + + +# --------------------------------------------------------------------------- +# Ask-delivery helpers +# --------------------------------------------------------------------------- + + def _delivery_outcome_for_limitation(assessment: LimitationAssessment) -> DeliveryOutcome: """Helper to delegate to deliver_ask, pure and testable.""" from core.epistemic_questions.delivery import deliver_ask return deliver_ask(assessment) +def _maybe_ask( + reason: str, + organ: str, + findings: list[Finding], + attempts: tuple[ComprehensionAttempt, ...], + text: str, + proposal_root: Path | None, + question_root: Path | None, + exercise_ask: bool, +) -> ContemplationResult | None: + """Return an ask ContemplationResult if *reason* maps to an askable family, else None. + + Centralises the family_by_name / assess_from_family / exercise_ask check that + previously appeared inline in every _solve_and_verify_r* function. + """ + from core.comprehension_attempt.failure_family import family_by_name + from core.epistemic_disclosure.limitation import assess_from_family + family_obj = family_by_name(reason) + if family_obj is None: + return None + assessment = assess_from_family(family_obj) + if assessment.resolution_action == "ask_question" and exercise_ask: + return _handle_ask_delivery( + assessment, family_obj.name, findings, attempts, + text, proposal_root, question_root, exercise_ask, + selected_organ=organ, + ) + return None + + def _handle_ask_delivery( assessment: LimitationAssessment, family_name: str, @@ -111,34 +218,112 @@ def _handle_ask_delivery( json.dumps(outcome.question.to_json_dict(), indent=2, sort_keys=True), encoding="utf-8", ) - findings.append(Finding("ask", f"emitted question-only {assessment.blocking_reason}")) - findings.append(Finding("terminal", Terminal.QUESTION_NEEDED.value)) - return ContemplationResult( - Terminal.QUESTION_NEEDED, tuple(findings), attempts, + return _result( + Terminal.QUESTION_NEEDED, findings, attempts, selected_organ=selected_organ, family=family_name, - proposal_path=None, - question_path=str(path), + proposal_path=None, question_path=str(path), ) else: findings.append(Finding("ask", f"unrenderable ask: {outcome.fallback_reason}")) - findings.append(Finding("terminal", outcome.terminal.value)) if outcome.terminal == Terminal.PROPOSAL_EMITTED: from core.comprehension_attempt.failure_family import family_by_name family_obj = family_by_name(family_name) if family_obj is not None: path = emit_proposal(text, family_obj, attempts, root=proposal_root) - return ContemplationResult( - Terminal.PROPOSAL_EMITTED, tuple(findings), attempts, + return _result( + Terminal.PROPOSAL_EMITTED, findings, attempts, selected_organ=selected_organ, family=family_name, proposal_path=str(path) if path else None, ) - return ContemplationResult( - outcome.terminal, tuple(findings), attempts, + return _result( + outcome.terminal, findings, attempts, selected_organ=selected_organ, family=family_name, ) +# --------------------------------------------------------------------------- +# Unified solve / verify +# --------------------------------------------------------------------------- + + +def _solve_and_verify( + pipeline: OrganPipeline, + text: str, + options: dict[str, Any] | None, + answer_key: str | None, + findings: list[Finding], + attempts: tuple[ComprehensionAttempt, ...], + proposal_root: Path | None, + question_root: Path | None, + exercise_ask: bool, +) -> ContemplationResult: + """Unified read → solve → maybe_ask → maybe_verify → terminal pipeline. + + Shared by every routed organ (R2, R3, R4, and future organs). The only + organ-specific variation is captured in *pipeline*. + """ + organ = pipeline.organ + + problem = pipeline.reader(text) + if isinstance(problem, Refusal): + # Routing guarantees the reader admits a setup — if it refuses here the + # route/read contract has been violated, which is an internal programming error. + raise InternalContemplationError( + f"{organ}: reader refused after routing admitted — " + f"routing invariant violated: {problem.reason}" + ) + + value = pipeline.solver(problem) + if isinstance(value, Refusal): + findings.append(Finding("solve", f"solver refused: {value.reason}")) + reason = pipeline.reason_adapter(value.reason) + if ask_result := _maybe_ask( + reason, organ, findings, attempts, text, proposal_root, question_root, exercise_ask + ): + return ask_result + return _result( + Terminal.REFUSED_KNOWN_BOUNDARY, findings, attempts, + selected_organ=organ, family=_family_name(reason), + ) + + if options is not None: + noun = ( + getattr(getattr(problem, "query", None), "unit", None) + if organ == "r2_constraints" + else None + ) + verdict = verify_answer_choice( + value, options, answer_key, + **({"noun": noun} if noun is not None else {}), + ) + if isinstance(verdict, Refusal): + findings.append(Finding("verify", f"answer-choice refused: {verdict.reason}")) + return _result( + Terminal.REFUSED_KNOWN_BOUNDARY, findings, attempts, + selected_organ=organ, answer=value, + ) + if verdict.status == "contradiction": + findings.append(Finding("verify", verdict.message)) + return _result( + Terminal.CONTRADICTION_DETECTED, findings, attempts, + selected_organ=organ, answer=value, + family="answer_key_contradiction", message=verdict.message, + ) + findings.append(Finding("verify", verdict.message)) + + findings.append(Finding("solve", f"value={value}")) + return _result( + Terminal.SOLVED_VERIFIED, findings, attempts, + selected_organ=organ, answer=value, + ) + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + + def contemplate( text: str, *, @@ -169,200 +354,31 @@ def contemplate( # Pass 3/4 — terminal (+ maybe emit). if route.status == "ambiguous": - findings.append(Finding("terminal", Terminal.AMBIGUOUS_ORGAN.value)) - return ContemplationResult(Terminal.AMBIGUOUS_ORGAN, tuple(findings), attempts) + return _result(Terminal.AMBIGUOUS_ORGAN, findings, attempts) if route.status == "routed": assert route.selected is not None - if route.selected.organ == "r2_constraints": - return _solve_and_verify_r2(text, options, answer_key, findings, attempts, proposal_root, question_root, exercise_ask) - if route.selected.organ == "r3_rate": - return _solve_and_verify_r3(text, options, answer_key, findings, attempts, proposal_root, question_root, exercise_ask) - if route.selected.organ == "r4_combined_rate": - return _solve_and_verify_cmb(text, options, answer_key, findings, attempts, proposal_root, question_root, exercise_ask) + if route.selected.organ in _PIPELINES: + return _solve_and_verify( + _PIPELINES[route.selected.organ], + text, options, answer_key, findings, attempts, + proposal_root, question_root, exercise_ask, + ) + # R1: numeric answer is the eval lane's domain in v0. findings.append(Finding("solve", "r1 admissible setup (numeric answer is the eval lane in v0)")) - findings.append(Finding("terminal", Terminal.SOLVED_VERIFIED.value)) - return ContemplationResult( - Terminal.SOLVED_VERIFIED, tuple(findings), attempts, selected_organ="r1_quantitative" + return _result( + Terminal.SOLVED_VERIFIED, findings, attempts, selected_organ="r1_quantitative" ) # route.status == "all_refused" - return _classify_all_refused(text, attempts, findings, proposal_root, question_root, exercise_ask) - - -def _solve_and_verify_r2( - text: str, - options: dict[str, Any] | None, - answer_key: str | None, - findings: list[Finding], - attempts: tuple[ComprehensionAttempt, ...], - proposal_root: Path | None, - question_root: Path | None, - exercise_ask: bool, -) -> ContemplationResult: - problem = read_constraint_problem(text) - assert not isinstance(problem, Refusal) # routed => the reader admitted a setup - value = answer_constraint_problem(problem) - if isinstance(value, Refusal): - findings.append(Finding("solve", f"solver refused: {value.reason}")) - from core.comprehension_attempt.failure_family import family_by_name - from core.epistemic_disclosure.limitation import assess_from_family - family_obj = family_by_name(value.reason) - if family_obj is not None: - assessment = assess_from_family(family_obj) - if assessment.resolution_action == "ask_question": - if exercise_ask: - return _handle_ask_delivery( - assessment, family_obj.name, findings, attempts, text, proposal_root, question_root, exercise_ask, - selected_organ="r2_constraints" - ) - findings.append(Finding("terminal", Terminal.REFUSED_KNOWN_BOUNDARY.value)) - return ContemplationResult( - Terminal.REFUSED_KNOWN_BOUNDARY, tuple(findings), attempts, - selected_organ="r2_constraints", family=_family_name(value.reason), - ) - if options is not None: - verdict = verify_answer_choice(value, options, answer_key, noun=problem.query.unit) - if isinstance(verdict, Refusal): - findings.append(Finding("verify", f"answer-choice refused: {verdict.reason}")) - findings.append(Finding("terminal", Terminal.REFUSED_KNOWN_BOUNDARY.value)) - return ContemplationResult( - Terminal.REFUSED_KNOWN_BOUNDARY, tuple(findings), attempts, - selected_organ="r2_constraints", answer=value, - ) - if verdict.status == "contradiction": - findings.append(Finding("verify", verdict.message)) - findings.append(Finding("terminal", Terminal.CONTRADICTION_DETECTED.value)) - return ContemplationResult( - Terminal.CONTRADICTION_DETECTED, tuple(findings), attempts, - selected_organ="r2_constraints", answer=value, - family="answer_key_contradiction", message=verdict.message, - ) - findings.append(Finding("verify", verdict.message)) - findings.append(Finding("solve", f"value={value}")) - findings.append(Finding("terminal", Terminal.SOLVED_VERIFIED.value)) - return ContemplationResult( - Terminal.SOLVED_VERIFIED, tuple(findings), attempts, - selected_organ="r2_constraints", answer=value, + return _classify_all_refused( + text, attempts, findings, proposal_root, question_root, exercise_ask ) -def _solve_and_verify_r3( - text: str, - options: dict[str, Any] | None, - answer_key: str | None, - findings: list[Finding], - attempts: tuple[ComprehensionAttempt, ...], - proposal_root: Path | None, - question_root: Path | None, - exercise_ask: bool, -) -> ContemplationResult: - problem = read_rate_problem(text) - assert not isinstance(problem, Refusal) # routed => the reader admitted a setup - value = solve_rate(problem) - if isinstance(value, Refusal): - findings.append(Finding("solve", f"solver refused: {value.reason}")) - from core.comprehension_attempt.failure_family import family_by_name - from core.epistemic_disclosure.limitation import assess_from_family - family_obj = family_by_name(value.reason) - if family_obj is not None: - assessment = assess_from_family(family_obj) - if assessment.resolution_action == "ask_question": - if exercise_ask: - return _handle_ask_delivery( - assessment, family_obj.name, findings, attempts, text, proposal_root, question_root, exercise_ask, - selected_organ="r3_rate" - ) - findings.append(Finding("terminal", Terminal.REFUSED_KNOWN_BOUNDARY.value)) - return ContemplationResult( - Terminal.REFUSED_KNOWN_BOUNDARY, tuple(findings), attempts, - selected_organ="r3_rate", family=_family_name(value.reason), - ) - if options is not None: - verdict = verify_answer_choice(value, options, answer_key) - if isinstance(verdict, Refusal): - findings.append(Finding("verify", f"answer-choice refused: {verdict.reason}")) - findings.append(Finding("terminal", Terminal.REFUSED_KNOWN_BOUNDARY.value)) - return ContemplationResult( - Terminal.REFUSED_KNOWN_BOUNDARY, tuple(findings), attempts, - selected_organ="r3_rate", answer=value, - ) - if verdict.status == "contradiction": - findings.append(Finding("verify", verdict.message)) - findings.append(Finding("terminal", Terminal.CONTRADICTION_DETECTED.value)) - return ContemplationResult( - Terminal.CONTRADICTION_DETECTED, tuple(findings), attempts, - selected_organ="r3_rate", answer=value, - family="answer_key_contradiction", message=verdict.message, - ) - findings.append(Finding("verify", verdict.message)) - findings.append(Finding("solve", f"value={value}")) - findings.append(Finding("terminal", Terminal.SOLVED_VERIFIED.value)) - return ContemplationResult( - Terminal.SOLVED_VERIFIED, tuple(findings), attempts, - selected_organ="r3_rate", answer=value, - ) - - -def _solve_and_verify_cmb( - text: str, - options: dict[str, Any] | None, - answer_key: str | None, - findings: list[Finding], - attempts: tuple[ComprehensionAttempt, ...], - proposal_root: Path | None, - question_root: Path | None, - exercise_ask: bool, -) -> ContemplationResult: - problem = read_combined_rate_problem(text) - assert not isinstance(problem, Refusal) # routed => the reader admitted a setup - value = solve_combined_rate(problem) - if isinstance(value, Refusal): - # The prose WAS understood (reader setup_correct); the resulting math is outside v1's - # answerable boundary. A solver refusal is a terminal boundary, never a proposal — and the - # reason is namespaced cmb_* so it resolves to the CMB solver family, not R2/R3's. - findings.append(Finding("solve", f"solver refused: {value.reason}")) - from core.comprehension_attempt.failure_family import family_by_name - from core.epistemic_disclosure.limitation import assess_from_family - reason = cmb_reason(value.reason) - family_obj = family_by_name(reason) - if family_obj is not None: - assessment = assess_from_family(family_obj) - if assessment.resolution_action == "ask_question": - if exercise_ask: - return _handle_ask_delivery( - assessment, family_obj.name, findings, attempts, text, proposal_root, question_root, exercise_ask, - selected_organ="r4_combined_rate" - ) - findings.append(Finding("terminal", Terminal.REFUSED_KNOWN_BOUNDARY.value)) - return ContemplationResult( - Terminal.REFUSED_KNOWN_BOUNDARY, tuple(findings), attempts, - selected_organ="r4_combined_rate", family=_family_name(cmb_reason(value.reason)), - ) - if options is not None: - verdict = verify_answer_choice(value, options, answer_key) - if isinstance(verdict, Refusal): - findings.append(Finding("verify", f"answer-choice refused: {verdict.reason}")) - findings.append(Finding("terminal", Terminal.REFUSED_KNOWN_BOUNDARY.value)) - return ContemplationResult( - Terminal.REFUSED_KNOWN_BOUNDARY, tuple(findings), attempts, - selected_organ="r4_combined_rate", answer=value, - ) - if verdict.status == "contradiction": - findings.append(Finding("verify", verdict.message)) - findings.append(Finding("terminal", Terminal.CONTRADICTION_DETECTED.value)) - return ContemplationResult( - Terminal.CONTRADICTION_DETECTED, tuple(findings), attempts, - selected_organ="r4_combined_rate", answer=value, - family="answer_key_contradiction", message=verdict.message, - ) - findings.append(Finding("verify", verdict.message)) - findings.append(Finding("solve", f"value={value}")) - findings.append(Finding("terminal", Terminal.SOLVED_VERIFIED.value)) - return ContemplationResult( - Terminal.SOLVED_VERIFIED, tuple(findings), attempts, - selected_organ="r4_combined_rate", answer=value, - ) +# --------------------------------------------------------------------------- +# All-refused classification +# --------------------------------------------------------------------------- def _classify_all_refused( @@ -374,8 +390,7 @@ def _classify_all_refused( exercise_ask: bool, ) -> ContemplationResult: # CMB-over-R3 precedence (family side): when CMB substantively recognized the text, R3's broader - # partial classification is suppressed, so CMB's sharper diagnosis owns the terminal/proposal - # (e.g. a reciprocal combined-rate text proposes a CMB fixture, not R3's rate_underdetermined). + # partial classification is suppressed, so CMB's sharper diagnosis owns the terminal/proposal. considered = attempts if cmb_is_authoritative(attempts): considered = tuple(a for a in attempts if a.organ != "r3_rate") @@ -390,8 +405,7 @@ def _classify_all_refused( if family.name in _UNSUPPORTED_FAMILIES else Terminal.REFUSED_KNOWN_BOUNDARY ) - findings.append(Finding("terminal", f"{terminal.value} via {family.name}")) - return ContemplationResult(terminal, tuple(findings), attempts, family=family.name) + return _result(terminal, findings, attempts, family=family.name) # Check for ASK delivery only after substantive boundaries are ruled out. for attempt in considered: @@ -400,7 +414,8 @@ def _classify_all_refused( if assessment is not None and assessment.resolution_action == "ask_question": if exercise_ask: return _handle_ask_delivery( - assessment, assessment.blocking_reason, findings, attempts, text, proposal_root, question_root, exercise_ask + assessment, assessment.blocking_reason, findings, attempts, + text, proposal_root, question_root, exercise_ask, ) # No substantive boundary: a genuine growth surface may emit a proposal-only artifact. @@ -408,14 +423,17 @@ def _classify_all_refused( if family is not None and family.proposal_allowed: path = emit_proposal(text, family, attempts, root=proposal_root) findings.append(Finding("propose", f"emitted proposal-only {family.name}")) - findings.append(Finding("terminal", Terminal.PROPOSAL_EMITTED.value)) - return ContemplationResult( - Terminal.PROPOSAL_EMITTED, tuple(findings), attempts, + return _result( + Terminal.PROPOSAL_EMITTED, findings, attempts, family=family.name, proposal_path=str(path) if path else None, ) - findings.append(Finding("terminal", Terminal.NO_PROGRESS.value)) - return ContemplationResult(Terminal.NO_PROGRESS, tuple(findings), attempts) + return _result(Terminal.NO_PROGRESS, findings, attempts) + + +# --------------------------------------------------------------------------- +# Internal utilities +# --------------------------------------------------------------------------- def _family_name(reason: str | None) -> str | None: @@ -423,4 +441,4 @@ def _family_name(reason: str | None) -> str | None: return family.name if family is not None else None -__all__ = ["ContemplationResult", "contemplate"] +__all__ = ["ContemplationResult", "contemplate", "InternalContemplationError"]