crucible-forge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. crucible/__init__.py +57 -0
  2. crucible/_spec.py +20 -0
  3. crucible/api/__init__.py +25 -0
  4. crucible/api/dispatcher.py +59 -0
  5. crucible/api/input_validation.py +54 -0
  6. crucible/api/phase_classification.py +14 -0
  7. crucible/api/phases/__init__.py +19 -0
  8. crucible/api/phases/_common.py +103 -0
  9. crucible/api/phases/cross_validation.py +70 -0
  10. crucible/api/phases/epic_decomposition.py +40 -0
  11. crucible/api/phases/epic_expansion.py +99 -0
  12. crucible/api/phases/planning_spec.py +82 -0
  13. crucible/api/phases/ticket_decomposition.py +47 -0
  14. crucible/api/phases/ticket_expansion.py +98 -0
  15. crucible/api/scope_resolver.py +70 -0
  16. crucible/api/validate.py +83 -0
  17. crucible/api/version.py +7 -0
  18. crucible/checks/__init__.py +14 -0
  19. crucible/checks/coverage.py +148 -0
  20. crucible/checks/na_validation.py +77 -0
  21. crucible/config/__init__.py +35 -0
  22. crucible/config/data/defaults.yml +198 -0
  23. crucible/config/defaults.py +185 -0
  24. crucible/config/load.py +45 -0
  25. crucible/config/merge.py +39 -0
  26. crucible/config/overrides.py +34 -0
  27. crucible/config/resolver.py +66 -0
  28. crucible/config/schema.py +106 -0
  29. crucible/config/snapshot.py +11 -0
  30. crucible/cross_validation/__init__.py +108 -0
  31. crucible/cross_validation/blueprint.py +61 -0
  32. crucible/cross_validation/dependency_graph.py +212 -0
  33. crucible/cross_validation/emission.py +13 -0
  34. crucible/cross_validation/files.py +154 -0
  35. crucible/cross_validation/ratios/__init__.py +0 -0
  36. crucible/cross_validation/topology.py +118 -0
  37. crucible/cross_validation/waves.py +237 -0
  38. crucible/engines/__init__.py +20 -0
  39. crucible/engines/ratios.py +85 -0
  40. crucible/engines/wave_calculator.py +85 -0
  41. crucible/guidance/__init__.py +0 -0
  42. crucible/guidance/composer/__init__.py +44 -0
  43. crucible/guidance/composer/patterns.py +304 -0
  44. crucible/guidance/engine/__init__.py +21 -0
  45. crucible/guidance/engine/applicability.py +24 -0
  46. crucible/guidance/engine/finding_engine.py +193 -0
  47. crucible/guidance/engine/resolver.py +73 -0
  48. crucible/guidance/engine/structural_checks.py +123 -0
  49. crucible/guidance/engine/value_resolver.py +31 -0
  50. crucible/guidance/fixed_prompts.py +52 -0
  51. crucible/guidance/format/__init__.py +68 -0
  52. crucible/guidance/format/individual_formatter.py +126 -0
  53. crucible/guidance/format/operations_vocabulary.py +29 -0
  54. crucible/guidance/format/templates.py +299 -0
  55. crucible/guidance/format/truncation.py +19 -0
  56. crucible/guidance/rubric/__init__.py +88 -0
  57. crucible/guidance/rubric/data/rubric.json +1414 -0
  58. crucible/models/__init__.py +91 -0
  59. crucible/models/_base.py +23 -0
  60. crucible/models/auxiliary.py +71 -0
  61. crucible/models/blueprint.py +21 -0
  62. crucible/models/enums.py +157 -0
  63. crucible/models/epic.py +40 -0
  64. crucible/models/planning.py +104 -0
  65. crucible/models/specification.py +59 -0
  66. crucible/models/ticket.py +53 -0
  67. crucible/py.typed +0 -0
  68. crucible/scoring/__init__.py +47 -0
  69. crucible/scoring/cascade.py +118 -0
  70. crucible/scoring/field_declarations.py +72 -0
  71. crucible/scoring/global_.py +69 -0
  72. crucible/scoring/per_entity.py +31 -0
  73. crucible/scoring/per_field.py +135 -0
  74. crucible/scoring/quality_checks/__init__.py +147 -0
  75. crucible/scoring/tiers.py +10 -0
  76. crucible/scoring/topology.py +73 -0
  77. crucible/structural/__init__.py +49 -0
  78. crucible/structural/_strict_schema.py +283 -0
  79. crucible/structural/duplicate_order.py +54 -0
  80. crucible/structural/entity_count_check.py +157 -0
  81. crucible/structural/format.py +78 -0
  82. crucible/structural/presence.py +41 -0
  83. crucible/structural/version_resolver.py +17 -0
  84. crucible/types/__init__.py +112 -0
  85. crucible/types/_base.py +30 -0
  86. crucible/types/cascade.py +20 -0
  87. crucible/types/config.py +36 -0
  88. crucible/types/context.py +30 -0
  89. crucible/types/enums.py +42 -0
  90. crucible/types/finding.py +49 -0
  91. crucible/types/guidance.py +41 -0
  92. crucible/types/operations.py +31 -0
  93. crucible/types/phase.py +33 -0
  94. crucible/types/result.py +170 -0
  95. crucible/types/rubric.py +48 -0
  96. crucible/types/scope.py +40 -0
  97. crucible_forge-0.1.0.dist-info/METADATA +204 -0
  98. crucible_forge-0.1.0.dist-info/RECORD +100 -0
  99. crucible_forge-0.1.0.dist-info/WHEEL +4 -0
  100. crucible_forge-0.1.0.dist-info/licenses/LICENSE +201 -0
crucible/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """crucible — deterministic validation engine for OpenSpec v1.1 specifications.
2
+
3
+ Faithful Python port of the SpecForge ``@specforge/validator`` engine.
4
+
5
+ ``validate(spec, context)`` is the single entry point. All four return layers
6
+ (``structural``, ``scoring``, ``crossValidation``, ``guidance``) are complete
7
+ and verified byte-for-byte against the reference engine.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from . import models, types
13
+ from .api import validate
14
+ from .api.input_validation import ValidatorInputError
15
+ from .config import (
16
+ CONFIG_DEFAULTS,
17
+ PLANNING_CONFIG_DOMAIN,
18
+ PLANNING_CONFIG_SCHEMA_VERSION,
19
+ ConfigValidationError,
20
+ PlanningConfigOverridesSchema,
21
+ PlanningConfigResolver,
22
+ ValidatorConfigSchema,
23
+ ValidatorConfigSnapshotSchema,
24
+ load_defaults,
25
+ load_from_file,
26
+ load_partial_from_file,
27
+ merge_config,
28
+ )
29
+ from .models import Blueprint, Epic, Specification, Ticket
30
+ from .structural import validate_structural
31
+
32
+ __all__ = [
33
+ "CONFIG_DEFAULTS",
34
+ "PLANNING_CONFIG_DOMAIN",
35
+ "PLANNING_CONFIG_SCHEMA_VERSION",
36
+ "Blueprint",
37
+ "ConfigValidationError",
38
+ "Epic",
39
+ "PlanningConfigOverridesSchema",
40
+ "PlanningConfigResolver",
41
+ "Specification",
42
+ "Ticket",
43
+ "ValidatorConfigSchema",
44
+ "ValidatorConfigSnapshotSchema",
45
+ "ValidatorInputError",
46
+ "__version__",
47
+ "load_defaults",
48
+ "load_from_file",
49
+ "load_partial_from_file",
50
+ "merge_config",
51
+ "models",
52
+ "types",
53
+ "validate",
54
+ "validate_structural",
55
+ ]
56
+
57
+ __version__ = "0.1.0"
crucible/_spec.py ADDED
@@ -0,0 +1,20 @@
1
+ """Spec input normalization.
2
+
3
+ The engine works on a camelCase ``dict`` (mirroring the TS value-resolver and
4
+ the JSON seeds). ``validate()`` and ``validate_structural()`` accept either a
5
+ :class:`crucible.models.Specification` or a plain mapping; both normalize here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel
13
+
14
+ SpecInput = BaseModel | dict[str, Any]
15
+
16
+
17
+ def to_spec_dict(spec: SpecInput) -> dict[str, Any]:
18
+ if isinstance(spec, BaseModel):
19
+ return spec.model_dump(mode="json", by_alias=True, exclude_none=True)
20
+ return dict(spec)
@@ -0,0 +1,25 @@
1
+ """Validator API layer (port of ``@specforge/validator`` ``src/api``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .input_validation import ValidatorInputError, validate_input
6
+ from .phase_classification import (
7
+ is_human_judgment_phase,
8
+ phase_includes_epics,
9
+ phase_includes_tickets,
10
+ )
11
+ from .scope_resolver import resolve_epic_scope, resolve_ticket_scope
12
+ from .validate import validate
13
+ from .version import get_validator_version
14
+
15
+ __all__ = [
16
+ "ValidatorInputError",
17
+ "get_validator_version",
18
+ "is_human_judgment_phase",
19
+ "phase_includes_epics",
20
+ "phase_includes_tickets",
21
+ "resolve_epic_scope",
22
+ "resolve_ticket_scope",
23
+ "validate",
24
+ "validate_input",
25
+ ]
@@ -0,0 +1,59 @@
1
+ """Phase dispatch (port of ``api/dispatcher.ts``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ from ..types.config import ValidatorConfig
9
+ from ..types.phase import SinglePhase
10
+ from ..types.result import ValidationResult, ValidationResultAll, ValidationResultAllMeta
11
+ from .phases import (
12
+ validate_cross_validation,
13
+ validate_epic_decomposition,
14
+ validate_epic_expansion,
15
+ validate_planning_spec,
16
+ validate_ticket_decomposition,
17
+ validate_ticket_expansion,
18
+ )
19
+ from .version import get_validator_version
20
+
21
+ _PHASES = {
22
+ "planning_spec": validate_planning_spec,
23
+ "epic_decomposition": validate_epic_decomposition,
24
+ "epic_expansion": validate_epic_expansion,
25
+ "ticket_decomposition": validate_ticket_decomposition,
26
+ "ticket_expansion": validate_ticket_expansion,
27
+ "cross_validation": validate_cross_validation,
28
+ }
29
+
30
+
31
+ def validate_single(
32
+ spec: dict[str, Any],
33
+ phase: SinglePhase,
34
+ active_entity_id: str | list[str] | None,
35
+ config: ValidatorConfig,
36
+ ) -> ValidationResult:
37
+ fn = _PHASES.get(phase)
38
+ if fn is None:
39
+ raise ValueError(f"Unknown phase: {phase}")
40
+ return fn(spec, active_entity_id, config)
41
+
42
+
43
+ def validate_all(
44
+ spec: dict[str, Any],
45
+ active_entity_id: str | list[str] | None,
46
+ config: ValidatorConfig,
47
+ ) -> ValidationResultAll:
48
+ by_phase = {
49
+ phase: fn(spec, active_entity_id, config) for phase, fn in _PHASES.items()
50
+ }
51
+ passed = all(r.passed for r in by_phase.values())
52
+ return ValidationResultAll(
53
+ passed=passed,
54
+ by_phase=by_phase,
55
+ meta=ValidationResultAllMeta(
56
+ validator_version=get_validator_version(),
57
+ generated_at=datetime.now(UTC).isoformat(),
58
+ ),
59
+ )
@@ -0,0 +1,54 @@
1
+ """Context input validation (port of ``api/input-validation.ts``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ _VALID_PHASES = {
8
+ "planning_spec",
9
+ "epic_decomposition",
10
+ "epic_expansion",
11
+ "ticket_decomposition",
12
+ "ticket_expansion",
13
+ "cross_validation",
14
+ "all",
15
+ }
16
+
17
+ # Only the per-entity SCORE phases require an active entity.
18
+ _PHASES_REQUIRING_ENTITY_ID = {"epic_expansion", "ticket_expansion"}
19
+
20
+ _VALID_RETURNS = {"structural", "scoring", "crossValidation", "guidance"}
21
+
22
+
23
+ class ValidatorInputError(Exception):
24
+ """Raised when ``validate`` receives an invalid context."""
25
+
26
+ def __init__(self, message: str) -> None:
27
+ super().__init__(f"[Validator] {message}")
28
+ self.name = "ValidatorInputError"
29
+
30
+
31
+ def validate_input(context: dict[str, Any]) -> None:
32
+ phase = context.get("phase")
33
+ if phase is not None and phase not in _VALID_PHASES:
34
+ raise ValidatorInputError(
35
+ f"Invalid phase '{phase}'. Must be one of: {', '.join(sorted(_VALID_PHASES))}"
36
+ )
37
+
38
+ active = context.get("activeEntityId")
39
+ has_entity_id = len(active) > 0 if isinstance(active, list) else active is not None
40
+
41
+ if phase is not None and phase in _PHASES_REQUIRING_ENTITY_ID and not has_entity_id:
42
+ raise ValidatorInputError(f"Phase '{phase}' requires activeEntityId")
43
+
44
+ returns = context.get("returns")
45
+ if returns:
46
+ for r in returns:
47
+ if r not in _VALID_RETURNS:
48
+ raise ValidatorInputError(
49
+ f"Invalid return layer '{r}'. Must be one of: "
50
+ f"{', '.join(sorted(_VALID_RETURNS))}"
51
+ )
52
+
53
+ if not context.get("config"):
54
+ raise ValidatorInputError("context.config is required")
@@ -0,0 +1,14 @@
1
+ """Phase classification helpers (port of ``api/phase-classification.ts``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..scoring.cascade import phase_includes_epics, phase_includes_tickets
6
+ from ..types.phase import ValidationPhase
7
+
8
+ _HUMAN_JUDGMENT_PHASES = {"epic_decomposition", "ticket_decomposition"}
9
+
10
+ __all__ = ["is_human_judgment_phase", "phase_includes_epics", "phase_includes_tickets"]
11
+
12
+
13
+ def is_human_judgment_phase(phase: ValidationPhase) -> bool:
14
+ return phase != "all" and phase in _HUMAN_JUDGMENT_PHASES
@@ -0,0 +1,19 @@
1
+ """Per-phase assembly functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cross_validation import validate_cross_validation
6
+ from .epic_decomposition import validate_epic_decomposition
7
+ from .epic_expansion import validate_epic_expansion
8
+ from .planning_spec import validate_planning_spec
9
+ from .ticket_decomposition import validate_ticket_decomposition
10
+ from .ticket_expansion import validate_ticket_expansion
11
+
12
+ __all__ = [
13
+ "validate_cross_validation",
14
+ "validate_epic_decomposition",
15
+ "validate_epic_expansion",
16
+ "validate_planning_spec",
17
+ "validate_ticket_decomposition",
18
+ "validate_ticket_expansion",
19
+ ]
@@ -0,0 +1,103 @@
1
+ """Shared helpers for phase assembly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ from ...guidance.composer import compose_findings
9
+ from ...guidance.engine import execute_rubric
10
+ from ...guidance.format import format_guidance
11
+ from ...scoring.global_ import GlobalScoreBreakdown as _InternalBreakdown
12
+ from ...scoring.per_field import PerFieldResult
13
+ from ...types.cascade import CascadeFailure, CascadeFloors
14
+ from ...types.context import PhaseContext
15
+ from ...types.guidance import GuidanceResult
16
+ from ...types.result import (
17
+ GlobalScoreBreakdown,
18
+ PerFieldScore,
19
+ ScoringResultActive,
20
+ ValidationResultMeta,
21
+ )
22
+ from ..version import get_validator_version
23
+
24
+
25
+ def build_guidance(spec: dict[str, Any], context: PhaseContext) -> GuidanceResult:
26
+ findings = execute_rubric(spec, context)
27
+ composites = compose_findings(findings, context, spec)
28
+ composed_ids = {f.rubric_entry_id for c in composites for f in c.grouped_findings}
29
+ individuals = [
30
+ f
31
+ for f in findings
32
+ if f.rubric_entry_id not in composed_ids and f.status != "fulfilled"
33
+ ]
34
+ return format_guidance(composites, individuals, context, spec)
35
+
36
+
37
+ def now_iso() -> str:
38
+ return datetime.now(UTC).isoformat()
39
+
40
+
41
+ def build_meta(
42
+ *,
43
+ active_entity_id: str | list[str] | None = None,
44
+ resolution: str | None = None,
45
+ warnings: list[str] | None = None,
46
+ ) -> ValidationResultMeta:
47
+ kwargs: dict[str, object] = {
48
+ "validator_version": get_validator_version(),
49
+ "generated_at": now_iso(),
50
+ }
51
+ if active_entity_id is not None:
52
+ kwargs["active_entity_id"] = active_entity_id
53
+ if resolution is not None:
54
+ kwargs["resolution"] = resolution
55
+ if warnings:
56
+ kwargs["warnings"] = warnings
57
+ return ValidationResultMeta(**kwargs)
58
+
59
+
60
+ def per_field_to_scores(per_field: dict[str, PerFieldResult]) -> dict[str, PerFieldScore]:
61
+ return {
62
+ k: PerFieldScore(earned=v.earned, possible=v.possible, tier=v.tier)
63
+ for k, v in per_field.items()
64
+ }
65
+
66
+
67
+ def breakdown_model(b: _InternalBreakdown) -> GlobalScoreBreakdown:
68
+ return GlobalScoreBreakdown(
69
+ spec_block=b.spec_block,
70
+ epic_block=b.epic_block,
71
+ ticket_block=b.ticket_block,
72
+ epic_avg=b.epic_avg,
73
+ ticket_avg=b.ticket_avg,
74
+ weights=b.weights,
75
+ )
76
+
77
+
78
+ def build_active_scoring(
79
+ *,
80
+ local_score: float,
81
+ per_field: dict[str, PerFieldScore],
82
+ breakdown: _InternalBreakdown,
83
+ cascade_floors: CascadeFloors,
84
+ cascade_failures: list[CascadeFailure],
85
+ gate_result: str,
86
+ threshold: float,
87
+ per_entity_score: dict[str, float] | None = None,
88
+ ) -> ScoringResultActive:
89
+ kwargs: dict[str, object] = {
90
+ "skipped": False,
91
+ "local_score": local_score,
92
+ "global_score": breakdown.global_score,
93
+ "per_field": per_field,
94
+ "topology_penalties": breakdown.topology_penalty,
95
+ "gate_result": gate_result,
96
+ "threshold": threshold,
97
+ "global_score_breakdown": breakdown_model(breakdown),
98
+ "cascade_floors": cascade_floors,
99
+ "cascade_failures": cascade_failures,
100
+ }
101
+ if per_entity_score is not None:
102
+ kwargs["per_entity_score"] = per_entity_score
103
+ return ScoringResultActive(**kwargs)
@@ -0,0 +1,70 @@
1
+ """cross_validation phase (port of ``api/phases/cross-validation.ts``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from ...cross_validation import run_cross_validation
9
+ from ...cross_validation.emission import CVEmission
10
+ from ...structural import validate_structural
11
+ from ...types.config import ValidatorConfig
12
+ from ...types.guidance import GuidanceCrossValidationEntry, GuidanceEntry, GuidanceResult
13
+ from ...types.result import ValidationResult
14
+ from ._common import build_meta
15
+
16
+
17
+ def _adapt_emission(emission: CVEmission) -> GuidanceCrossValidationEntry:
18
+ f = emission.finding
19
+ kwargs: dict[str, Any] = {
20
+ "pattern_id": "cross-validation",
21
+ "category": f.category,
22
+ "severity": f.severity,
23
+ "field_path": f.field,
24
+ "primary_entity_id": f.primary_entity_id,
25
+ "entity_ids": f.entity_ids,
26
+ "message": emission.guidance,
27
+ "operations": f.operations,
28
+ "points_lost": 0,
29
+ "global_impact_on_fix": 0,
30
+ }
31
+ if f.context is not None:
32
+ kwargs["context"] = f.context
33
+ return GuidanceCrossValidationEntry(**kwargs)
34
+
35
+
36
+ def validate_cross_validation(
37
+ spec: dict[str, Any], active_entity_id: str | list[str] | None, config: ValidatorConfig
38
+ ) -> ValidationResult:
39
+ warnings: list[str] = []
40
+ if active_entity_id is not None:
41
+ warnings.append(
42
+ f"cross_validation phase ignores activeEntityId; received: "
43
+ f"{json.dumps(active_entity_id)}"
44
+ )
45
+
46
+ structural = validate_structural(spec, config, "cross_validation")
47
+ run = run_cross_validation(spec, config, "cross_validation")
48
+ cross_validation = run.result
49
+
50
+ per_entity: dict[str, list[GuidanceEntry]] = {}
51
+ for emission in run.emissions:
52
+ adapted = _adapt_emission(emission)
53
+ for entity_id in emission.finding.entity_ids:
54
+ per_entity.setdefault(entity_id, []).append(adapted)
55
+ guidance = GuidanceResult(per_entity=per_entity)
56
+
57
+ passed = (
58
+ not structural.invalid_fields
59
+ and not any(f.severity == "error" for f in structural.findings)
60
+ and not cross_validation.findings
61
+ )
62
+
63
+ return ValidationResult(
64
+ phase="cross_validation",
65
+ passed=passed,
66
+ structural=structural,
67
+ cross_validation=cross_validation,
68
+ guidance=guidance,
69
+ meta=build_meta(active_entity_id=active_entity_id, warnings=warnings or None),
70
+ )
@@ -0,0 +1,40 @@
1
+ """epic_decomposition phase (port of ``api/phases/epic-decomposition.ts``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ...engines.ratios import check_blueprint_epic_ratio
8
+ from ...guidance.fixed_prompts import build_epic_decomposition_guidance
9
+ from ...structural import validate_structural
10
+ from ...types.config import ValidatorConfig
11
+ from ...types.result import CrossValidationResult, ScoringResultSkipped, ValidationResult
12
+ from ._common import build_meta
13
+
14
+
15
+ def validate_epic_decomposition(
16
+ spec: dict[str, Any], _active_entity_id: str | list[str] | None, config: ValidatorConfig
17
+ ) -> ValidationResult:
18
+ structural = validate_structural(spec, config, "epic_decomposition")
19
+ ratio_findings = check_blueprint_epic_ratio(spec, config)
20
+
21
+ passed = (
22
+ not ratio_findings
23
+ and not structural.invalid_fields
24
+ and not any(f.severity == "error" for f in structural.findings)
25
+ )
26
+
27
+ return ValidationResult(
28
+ phase="epic_decomposition",
29
+ passed=passed,
30
+ structural=structural,
31
+ scoring=ScoringResultSkipped(),
32
+ cross_validation=CrossValidationResult(
33
+ skipped=False,
34
+ findings=ratio_findings,
35
+ ran_checks=["blueprint-epic-ratio"],
36
+ skipped_checks=[],
37
+ ),
38
+ guidance=build_epic_decomposition_guidance(spec),
39
+ meta=build_meta(),
40
+ )
@@ -0,0 +1,99 @@
1
+ """epic_expansion phase (port of ``api/phases/epic-expansion.ts``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ...checks.coverage import validate_nfr_coverage, validate_requirement_coverage
8
+ from ...checks.na_validation import validate_na_epic
9
+ from ...guidance.rubric import get_rubric_for_phase
10
+ from ...scoring import (
11
+ CascadeGateInput,
12
+ compute_cascade_floors,
13
+ compute_entity_score,
14
+ compute_global_score,
15
+ evaluate_cascade_gate,
16
+ )
17
+ from ...structural import validate_structural
18
+ from ...types.config import ValidatorConfig
19
+ from ...types.context import PhaseContext
20
+ from ...types.result import CrossValidationResult, ValidationResult
21
+ from ..scope_resolver import resolve_epic_scope
22
+ from ._common import build_active_scoring, build_guidance, build_meta
23
+
24
+
25
+ def validate_epic_expansion(
26
+ spec: dict[str, Any], active_entity_id: str | list[str] | None, config: ValidatorConfig
27
+ ) -> ValidationResult:
28
+ scoped_epics = resolve_epic_scope(spec, active_entity_id)
29
+ structural = validate_structural(spec, config, "epic_expansion")
30
+
31
+ epic_entries = [
32
+ e for e in get_rubric_for_phase("epic_expansion") if e.field_path.startswith("epic.")
33
+ ]
34
+ per_entity_score: dict[str, float] = {}
35
+ scores: list[float] = []
36
+ for epic in scoped_epics:
37
+ result = compute_entity_score(epic, epic_entries, config)
38
+ per_entity_score[epic["id"]] = result.local_score
39
+ scores.append(result.local_score)
40
+ avg_local = 100.0 if not scores else sum(scores) / len(scores)
41
+
42
+ breakdown = compute_global_score(spec, config["scoring"]["weights"], config)
43
+ cascade_floors = compute_cascade_floors(config)
44
+ cascade = evaluate_cascade_gate(
45
+ CascadeGateInput(
46
+ global_score=breakdown.global_score,
47
+ spec_block=breakdown.spec_block,
48
+ epic_block=breakdown.epic_block,
49
+ phase="epic_expansion",
50
+ floors=cascade_floors,
51
+ )
52
+ )
53
+
54
+ threshold = config["thresholds"]["epic"]
55
+ gate_result = "pass" if avg_local >= threshold and cascade.gate_result == "pass" else "fail"
56
+
57
+ scoring = build_active_scoring(
58
+ local_score=avg_local,
59
+ per_field={},
60
+ breakdown=breakdown,
61
+ cascade_floors=cascade_floors,
62
+ cascade_failures=cascade.cascade_failures,
63
+ gate_result=gate_result,
64
+ threshold=threshold,
65
+ per_entity_score=per_entity_score,
66
+ )
67
+
68
+ na_findings = [f for e in scoped_epics for f in validate_na_epic(e, config)]
69
+ coverage_findings = [
70
+ *validate_requirement_coverage(spec, scoped_epics, config),
71
+ *validate_nfr_coverage(spec, scoped_epics, config),
72
+ ]
73
+ all_local = [*na_findings, *coverage_findings]
74
+
75
+ passed = (
76
+ gate_result == "pass"
77
+ and not structural.invalid_fields
78
+ and not any(f.severity == "error" for f in structural.findings)
79
+ and not all_local
80
+ )
81
+
82
+ guidance = build_guidance(
83
+ spec, PhaseContext(phase="epic_expansion", config=config, active_entity_id=active_entity_id)
84
+ )
85
+
86
+ return ValidationResult(
87
+ phase="epic_expansion",
88
+ passed=passed,
89
+ structural=structural,
90
+ scoring=scoring,
91
+ cross_validation=CrossValidationResult(
92
+ skipped=False,
93
+ findings=all_local,
94
+ ran_checks=["na-validation", "requirement-coverage", "nfr-coverage"],
95
+ skipped_checks=[],
96
+ ),
97
+ guidance=guidance,
98
+ meta=build_meta(active_entity_id=active_entity_id),
99
+ )
@@ -0,0 +1,82 @@
1
+ """planning_spec phase (port of ``api/phases/planning-spec.ts``).
2
+
3
+ Guidance layer deferred (C7) — ``guidance`` is left unset.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from ...checks.na_validation import validate_na_spec
11
+ from ...guidance.rubric import get_rubric_for_phase
12
+ from ...scoring import (
13
+ CascadeGateInput,
14
+ compute_cascade_floors,
15
+ compute_entity_score,
16
+ compute_global_score,
17
+ evaluate_cascade_gate,
18
+ )
19
+ from ...structural import validate_structural
20
+ from ...types.config import ValidatorConfig
21
+ from ...types.context import PhaseContext
22
+ from ...types.result import ValidationResult
23
+ from ._common import build_active_scoring, build_guidance, build_meta, per_field_to_scores
24
+
25
+
26
+ def validate_planning_spec(
27
+ spec: dict[str, Any], active_entity_id: str | list[str] | None, config: ValidatorConfig
28
+ ) -> ValidationResult:
29
+ structural = validate_structural(spec, config, "planning_spec")
30
+
31
+ spec_entries = [
32
+ e for e in get_rubric_for_phase("planning_spec") if e.field_path.startswith("specification.")
33
+ ]
34
+ entity_score = compute_entity_score(spec, spec_entries, config)
35
+
36
+ breakdown = compute_global_score(spec, config["scoring"]["weights"], config)
37
+ cascade_floors = compute_cascade_floors(config)
38
+ cascade = evaluate_cascade_gate(
39
+ CascadeGateInput(
40
+ global_score=breakdown.global_score,
41
+ spec_block=breakdown.spec_block,
42
+ epic_block=breakdown.epic_block,
43
+ phase="planning_spec",
44
+ floors=cascade_floors,
45
+ )
46
+ )
47
+
48
+ threshold = config["thresholds"]["specification"]
49
+ gate_result = (
50
+ "pass" if entity_score.local_score >= threshold and cascade.gate_result == "pass" else "fail"
51
+ )
52
+
53
+ scoring = build_active_scoring(
54
+ local_score=entity_score.local_score,
55
+ per_field=per_field_to_scores(entity_score.per_field),
56
+ breakdown=breakdown,
57
+ cascade_floors=cascade_floors,
58
+ cascade_failures=cascade.cascade_failures,
59
+ gate_result=gate_result,
60
+ threshold=threshold,
61
+ )
62
+
63
+ na_findings = validate_na_spec(spec, config)
64
+ passed = (
65
+ gate_result == "pass"
66
+ and not structural.invalid_fields
67
+ and not any(f.severity == "error" for f in structural.findings)
68
+ and not na_findings
69
+ )
70
+
71
+ guidance = build_guidance(
72
+ spec, PhaseContext(phase="planning_spec", config=config, active_entity_id=active_entity_id)
73
+ )
74
+
75
+ return ValidationResult(
76
+ phase="planning_spec",
77
+ passed=passed,
78
+ structural=structural,
79
+ scoring=scoring,
80
+ guidance=guidance,
81
+ meta=build_meta(active_entity_id=active_entity_id),
82
+ )