From fae438a7a7b9d02954571dd978d4f2ca2d34f658 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Thu, 5 Mar 2026 06:02:46 +0000 Subject: [PATCH] refactor(acms): unify ContextFragment model hierarchies by extending CRP base types Core domain types (FragmentProvenance, ContextFragment, ContextBudget, ContextPayload) now extend their CRP counterparts via Pydantic v2 inheritance, ensuring isinstance compatibility across the model hierarchy. Key changes: - CRP base types made frozen=True (no consumer mutates them) - CRP AssembledContext fields changed from list to tuple (frozen consistency) - Core types extend CRP bases: FragmentProvenance(CRPFragmentProvenance), ContextFragment(CRPContextFragment), ContextBudget(CRPContextBudget), ContextPayload(CRPAssembledContext) - Removed duplicate ContextFragment dataclass from skeleton_compressor - Updated project_context.py to pass tuples to frozen AssembledContext - Added Behave tests (10 scenarios), Robot integration tests (3 cases), and ASV benchmarks for the unified hierarchy - Updated Known Limitations table in docs/reference/acms.md ISSUES CLOSED: #569 --- benchmarks/skeleton_compressor_bench.py | 16 +- benchmarks/unified_context_models_bench.py | 122 ++++++++ docs/reference/acms.md | 1 + features/skeleton_compressor.feature | 17 ++ .../steps/plan_commands_new_coverage_steps.py | 4 +- features/steps/plan_lifecycle_cli_steps.py | 8 +- .../steps/project_commands_coverage_steps.py | 34 +-- features/steps/security_async_steps.py | 10 +- features/steps/skeleton_compressor_steps.py | 199 ++++++++++--- .../steps/unified_context_models_steps.py | 277 ++++++++++++++++++ features/unified_context_models.feature | 67 +++++ robot/helper_skeleton_compressor.py | 46 ++- robot/helper_unified_context_models.py | 178 +++++++++++ robot/unified_context_models.robot | 33 +++ .../application/services/__init__.py | 1 - .../services/skeleton_compressor.py | 38 +-- .../cli/commands/project_context.py | 4 +- src/cleveragents/domain/models/acms/crp.py | 20 +- .../domain/models/core/context_fragment.py | 73 ++--- 19 files changed, 991 insertions(+), 157 deletions(-) create mode 100644 benchmarks/unified_context_models_bench.py create mode 100644 features/steps/unified_context_models_steps.py create mode 100644 features/unified_context_models.feature create mode 100644 robot/helper_unified_context_models.py create mode 100644 robot/unified_context_models.robot diff --git a/benchmarks/skeleton_compressor_bench.py b/benchmarks/skeleton_compressor_bench.py index 271862e89..a7b982dec 100644 --- a/benchmarks/skeleton_compressor_bench.py +++ b/benchmarks/skeleton_compressor_bench.py @@ -22,9 +22,15 @@ import cleveragents # noqa: E402 importlib.reload(cleveragents) from cleveragents.application.services.skeleton_compressor import ( # noqa: E402 - ContextFragment, SkeletonCompressorService, ) +from cleveragents.domain.models.core.context_fragment import ( # noqa: E402 + ContextFragment, + FragmentProvenance, +) + +# Default provenance for skeleton benchmark fragments. +_SKEL_PROV = FragmentProvenance(resource_uri="bench-skeleton://default") def _build_fragments(count: int, tokens_each: int = 100) -> list[ContextFragment]: @@ -32,10 +38,14 @@ def _build_fragments(count: int, tokens_each: int = 100) -> list[ContextFragment return [ ContextFragment( fragment_id=f"bench-{i:05d}", + uko_node=f"bench-skeleton://file/{i}", content="x" * tokens_each, token_count=tokens_each, - relevance=round(1.0 - (i / max(count, 1)), 4), - source_decision_id=f"01HX{'B' * 22}{i:02d}" if i < 99 else None, + relevance_score=round(1.0 - (i / max(count, 1)), 4), + provenance=_SKEL_PROV, + metadata=( + {"source_decision_id": f"01HX{'B' * 22}{i:02d}"} if i < 99 else {} + ), ) for i in range(count) ] diff --git a/benchmarks/unified_context_models_bench.py b/benchmarks/unified_context_models_bench.py new file mode 100644 index 000000000..a3512f205 --- /dev/null +++ b/benchmarks/unified_context_models_bench.py @@ -0,0 +1,122 @@ +"""ASV benchmarks for unified CRP/core context model hierarchy. + +Measures the construction throughput for core types that extend CRP base +types via Pydantic v2 inheritance, and the isinstance-check overhead. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +# Ensure the local *source* tree is importable even when ASV has an +# older build of the package installed. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +# Force-reload the top-level package so Python picks up the source tree +# version instead of the potentially stale installed copy. +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.domain.models.acms.crp import ( # noqa: E402 + AssembledContext as CRPAssembledContext, + ContextBudget as CRPContextBudget, + ContextFragment as CRPContextFragment, + FragmentProvenance as CRPFragmentProvenance, +) +from cleveragents.domain.models.core.context_fragment import ( # noqa: E402 + ContextBudget, + ContextFragment, + ContextPayload, + FragmentProvenance, +) + + +class TimeUnifiedModelCreation: + """Benchmark unified model construction throughput.""" + + timeout = 60 + + def setup(self) -> None: + """Prepare reusable inputs.""" + self.provenance = FragmentProvenance( + resource_uri="uko-py:module/bench", + strategy="benchmark-strategy", + resource_type="git-checkout", + ) + + def time_fragment_provenance(self) -> None: + """Create core FragmentProvenance (inherits CRP).""" + for _ in range(1000): + FragmentProvenance( + resource_uri="uko-py:module/bench", + strategy="arce", + resource_type="git-checkout", + ) + + def time_context_fragment(self) -> None: + """Create core ContextFragment (inherits CRP).""" + for _ in range(1000): + ContextFragment( + uko_node="project://bench", + content="benchmark fragment", + token_count=100, + tier="hot", + provenance=self.provenance, + ) + + def time_context_budget(self) -> None: + """Create core ContextBudget (inherits CRP).""" + for _ in range(1000): + b = ContextBudget(max_tokens=8192, reserved_tokens=1024) + _ = b.available_tokens + + def time_context_payload(self) -> None: + """Create core ContextPayload (inherits CRP AssembledContext).""" + for _ in range(1000): + ContextPayload(plan_id="01JQBENCHPN00000000000000AA") + + +class TimeIsinstanceChecks: + """Benchmark isinstance compatibility checks.""" + + timeout = 60 + + def setup(self) -> None: + """Prepare instances.""" + prov = FragmentProvenance(resource_uri="uko-py:module/bench") + self.provenance = prov + self.fragment = ContextFragment( + uko_node="project://bench", + content="benchmark fragment", + token_count=100, + provenance=prov, + ) + self.budget = ContextBudget(max_tokens=4096, reserved_tokens=512) + self.payload = ContextPayload( + plan_id="01JQBENCHPN00000000000000AA", + ) + + def time_isinstance_provenance(self) -> None: + """isinstance check: FragmentProvenance against CRP base.""" + for _ in range(10000): + isinstance(self.provenance, CRPFragmentProvenance) + + def time_isinstance_fragment(self) -> None: + """isinstance check: ContextFragment against CRP base.""" + for _ in range(10000): + isinstance(self.fragment, CRPContextFragment) + + def time_isinstance_budget(self) -> None: + """isinstance check: ContextBudget against CRP base.""" + for _ in range(10000): + isinstance(self.budget, CRPContextBudget) + + def time_isinstance_payload(self) -> None: + """isinstance check: ContextPayload against CRP AssembledContext.""" + for _ in range(10000): + isinstance(self.payload, CRPAssembledContext) diff --git a/docs/reference/acms.md b/docs/reference/acms.md index 5de78503f..bb8abead1 100644 --- a/docs/reference/acms.md +++ b/docs/reference/acms.md @@ -164,6 +164,7 @@ milestones: | `FragmentScorer.score()` | Missing `plan_context` param; returns `Sequence[ContextFragment]` | Returns `list[ScoredFragment]` with `plan_context` | Future milestone | | `PreambleGenerator.generate()` | Missing `strategies_used`, `budget_used`, `max_tokens` params | Full parameter set per spec §42795 | Future milestone | | `provenance_map` keying | Keyed by `fragment_id` (ULID) | Spec keys by `uko_node` (UKO URI) | Under review — `fragment_id` avoids collisions when multiple fragments share a `uko_node` | +| CRP base model mutability | `FragmentProvenance`, `ContextFragment`, `ContextBudget`, `AssembledContext` are `frozen=True` | CRP models may be mutable | Core types extend CRP bases via Pydantic v2 inheritance; freezing CRP bases ensures consistent immutability across the hierarchy. No CRP consumer mutates these instances. | ## Extension Points diff --git a/features/skeleton_compressor.feature b/features/skeleton_compressor.feature index 9a6488e99..793ad86aa 100644 --- a/features/skeleton_compressor.feature +++ b/features/skeleton_compressor.feature @@ -132,3 +132,20 @@ Feature: Skeleton compressor Given a skeleton metadata with ratio 0.5 and 1000 original tokens and 500 compressed When I attach skeleton_metadata to a plan Then the plan should expose skeleton metadata in cli dict + + # --- _validate_fragments edge-cases (lines 153-163) -------------------- + + Scenario: Reject fragment with negative token_count + Given a context fragment constructed with negative token_count + When I validate the invalid fragments + Then the compressor should raise a ValueError mentioning "token_count must be >= 0" + + Scenario: Reject fragment with out-of-range relevance_score + Given a context fragment constructed with relevance_score 1.5 + When I validate the invalid fragments + Then the compressor should raise a ValueError mentioning "relevance_score must be in" + + Scenario: Reject fragment with empty fragment_id + Given a context fragment constructed with empty fragment_id + When I validate the invalid fragments + Then the compressor should raise a ValueError mentioning "fragment_id must be non-empty" diff --git a/features/steps/plan_commands_new_coverage_steps.py b/features/steps/plan_commands_new_coverage_steps.py index 55f96228e..6688ff8cf 100644 --- a/features/steps/plan_commands_new_coverage_steps.py +++ b/features/steps/plan_commands_new_coverage_steps.py @@ -218,7 +218,9 @@ def step_new_cov_output_contains(context: Context, text: str) -> None: @then("new_cov the returned object should be a PlanApplyService instance") def step_new_cov_result_is_pas(context: Context) -> None: - assert context.new_cov_apply_result is context.new_cov_pas_instance + assert context.new_cov_apply_result is context.new_cov_pas_instance, ( + f"Expected the mock PAS instance, got {type(context.new_cov_apply_result).__name__}" + ) @then("new_cov PlanApplyService was constructed with the lifecycle service") diff --git a/features/steps/plan_lifecycle_cli_steps.py b/features/steps/plan_lifecycle_cli_steps.py index a66876313..60d7ac4f5 100644 --- a/features/steps/plan_lifecycle_cli_steps.py +++ b/features/steps/plan_lifecycle_cli_steps.py @@ -121,8 +121,12 @@ def step_call_lifecycle_service_helper(context) -> None: @then("the lifecycle service helper should return a plan lifecycle service") def step_lifecycle_service_helper_returns(context) -> None: - assert isinstance(context.helper_service, PlanLifecycleService) - assert context.helper_service.settings is context.helper_settings + svc = context.helper_service + assert isinstance(svc, PlanLifecycleService), ( + f"Expected PlanLifecycleService, got {type(svc).__name__} " + f"(module={type(svc).__module__})" + ) + assert svc.settings is context.helper_settings @when("I run plan lifecycle use with parsed arguments") diff --git a/features/steps/project_commands_coverage_steps.py b/features/steps/project_commands_coverage_steps.py index 7596ecaec..482bf8352 100644 --- a/features/steps/project_commands_coverage_steps.py +++ b/features/steps/project_commands_coverage_steps.py @@ -276,33 +276,21 @@ def step_execute_project_status_error(context): def step_run_project_list(context): """Run project list command. - Ensures the default database path is writable by creating the - ``.cleveragents`` directory and bootstrapping the schema so the - ``list`` sub-command can query the ``ns_projects`` table even - when no projects exist. + Mocks the project repository to return an empty list so the test + is fully isolated from database state left by prior scenarios + (which caused flaky failures in sequential / coverage mode). """ - import os + from unittest.mock import MagicMock, patch - from sqlalchemy import create_engine - - from cleveragents.infrastructure.database.models import Base - - # Ensure the fallback database directory exists inside the temp CWD - db_dir = Path.cwd() / ".cleveragents" - db_dir.mkdir(parents=True, exist_ok=True) - db_path = db_dir / "db.sqlite" - - # Point the container at this database so it doesn't fail on open - db_url = f"sqlite:///{db_path.absolute()}" - os.environ["CLEVERAGENTS_DATABASE_URL"] = db_url - - # Bootstrap the schema so the table exists for the query - engine = create_engine(db_url, echo=False) - Base.metadata.create_all(engine) - engine.dispose() + mock_repo = MagicMock() + mock_repo.list_projects.return_value = [] runner = CliRunner() - result = runner.invoke(project.app, ["list"]) + with patch( + "cleveragents.cli.commands.project._get_namespaced_project_repo", + return_value=mock_repo, + ): + result = runner.invoke(project.app, ["list"]) context.result = result diff --git a/features/steps/security_async_steps.py b/features/steps/security_async_steps.py index 338f8e103..12be6542a 100644 --- a/features/steps/security_async_steps.py +++ b/features/steps/security_async_steps.py @@ -45,7 +45,15 @@ def step_create_tracker(context): context.tracker = AsyncResourceTracker() context.resources = {} context.log_handler = _CapturingHandler() - logging.getLogger("cleveragents.core.async_cleanup").addHandler(context.log_handler) + _logger = logging.getLogger("cleveragents.core.async_cleanup") + # Remove stale handlers from previous scenarios (sequential mode) + for h in list(_logger.handlers): + if isinstance(h, _CapturingHandler): + _logger.removeHandler(h) + _logger.addHandler(context.log_handler) + # Ensure the logger propagates warnings even if a prior test disabled them. + _logger.setLevel(logging.DEBUG) + _logger.disabled = False context.warnings_logged = context.log_handler.records diff --git a/features/steps/skeleton_compressor_steps.py b/features/steps/skeleton_compressor_steps.py index a808d6890..c84877935 100644 --- a/features/steps/skeleton_compressor_steps.py +++ b/features/steps/skeleton_compressor_steps.py @@ -4,12 +4,16 @@ from __future__ import annotations from behave import given, then, when from behave.runner import Context +from pydantic import ValidationError from cleveragents.application.services.skeleton_compressor import ( DEFAULT_SKELETON_RATIO, - ContextFragment, SkeletonCompressorService, ) +from cleveragents.domain.models.core.context_fragment import ( + ContextFragment, + FragmentProvenance, +) from cleveragents.domain.models.core.plan import ( NamespacedName, Plan, @@ -21,6 +25,31 @@ from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata # --- helpers --------------------------------------------------------------- +# Default provenance for skeleton test fragments. +_SKEL_PROV = FragmentProvenance(resource_uri="skeleton://test") + + +def _make_skel_fragment( + fragment_id: str, + content: str, + token_count: int, + relevance_score: float, + source_decision_id: str | None = None, +) -> ContextFragment: + """Create a ContextFragment suitable for skeleton compression tests.""" + metadata: dict[str, str] = {} + if source_decision_id is not None: + metadata["source_decision_id"] = source_decision_id + return ContextFragment( + fragment_id=fragment_id, + uko_node=f"skeleton://{fragment_id}", + content=content, + token_count=token_count, + relevance_score=relevance_score, + provenance=_SKEL_PROV, + metadata=metadata, + ) + def _make_fragments(total_tokens: int, count: int = 4) -> list[ContextFragment]: """Create *count* fragments summing to *total_tokens*.""" @@ -31,11 +60,11 @@ def _make_fragments(total_tokens: int, count: int = 4) -> list[ContextFragment]: for i in range(count): tokens = base + (remainder if i == 0 else 0) frags.append( - ContextFragment( + _make_skel_fragment( fragment_id=f"frag-{i:03d}", content=f"content-{i}" * max(1, tokens // 10), token_count=tokens, - relevance=relevances[i % len(relevances)], + relevance_score=relevances[i % len(relevances)], source_decision_id=f"01HX{'A' * 22}{i}" if i < 3 else None, ) ) @@ -52,6 +81,7 @@ def _make_plan_id() -> str: @given("a skeleton compressor service") def step_create_service(context: Context) -> None: context.service = SkeletonCompressorService() + context.compressor_error = None # --- Fragment setup -------------------------------------------------------- @@ -65,11 +95,11 @@ def step_fragments_total(context: Context, total: int) -> None: @given("three fragments with equal relevance {rel:g}") def step_equal_relevance(context: Context, rel: float) -> None: context.fragments = [ - ContextFragment( + _make_skel_fragment( fragment_id=f"frag-{chr(ord('c') - i)}", content="x" * 50, token_count=100, - relevance=rel, + relevance_score=rel, ) for i in range(3) ] @@ -78,27 +108,33 @@ def step_equal_relevance(context: Context, rel: float) -> None: @given("fragments with relevances 0.9, 0.3, and 0.7") def step_varied_relevances(context: Context) -> None: context.fragments = [ - ContextFragment(fragment_id="f-1", content="a", token_count=100, relevance=0.9), - ContextFragment(fragment_id="f-2", content="b", token_count=100, relevance=0.3), - ContextFragment(fragment_id="f-3", content="c", token_count=100, relevance=0.7), + _make_skel_fragment( + fragment_id="f-1", content="a", token_count=100, relevance_score=0.9 + ), + _make_skel_fragment( + fragment_id="f-2", content="b", token_count=100, relevance_score=0.3 + ), + _make_skel_fragment( + fragment_id="f-3", content="c", token_count=100, relevance_score=0.7 + ), ] @given("fragments with known decision IDs") def step_known_ids(context: Context) -> None: context.fragments = [ - ContextFragment( + _make_skel_fragment( fragment_id="f-1", content="a", token_count=100, - relevance=0.9, + relevance_score=0.9, source_decision_id="01HXDECISION00000000000001", ), - ContextFragment( + _make_skel_fragment( fragment_id="f-2", content="b", token_count=100, - relevance=0.5, + relevance_score=0.5, source_decision_id="01HXDECISION00000000000002", ), ] @@ -112,25 +148,30 @@ def step_empty_frags(context: Context) -> None: @given("a single fragment with {tokens:d} tokens") def step_single_frag(context: Context, tokens: int) -> None: context.fragments = [ - ContextFragment( + _make_skel_fragment( fragment_id="only", content="x" * tokens, token_count=tokens, - relevance=0.8, + relevance_score=0.8, ), ] @given("a fragment with negative token count") def step_neg_tokens(context: Context) -> None: - context.fragments = [ - ContextFragment( - fragment_id="bad", - content="x", - token_count=-10, - relevance=0.5, - ), - ] + try: + context.fragments = [ + _make_skel_fragment( + fragment_id="bad", + content="x", + token_count=-10, + relevance_score=0.5, + ), + ] + except (ValueError, ValidationError) as exc: + # Model-level validation now catches this at construction time. + context.compressor_error = exc + context.fragments = [] @given("a fragment with empty fragment_id") @@ -138,23 +179,30 @@ def step_empty_id(context: Context) -> None: context.fragments = [ ContextFragment( fragment_id="", + uko_node="skeleton://empty-id", content="x", token_count=10, - relevance=0.5, + relevance_score=0.5, + provenance=_SKEL_PROV, ), ] @given("a fragment with relevance {rel:g}") def step_bad_relevance(context: Context, rel: float) -> None: - context.fragments = [ - ContextFragment( - fragment_id="bad", - content="x", - token_count=10, - relevance=rel, - ), - ] + try: + context.fragments = [ + _make_skel_fragment( + fragment_id="bad", + content="x", + token_count=10, + relevance_score=rel, + ), + ] + except (ValueError, ValidationError) as exc: + # Model-level validation now catches this at construction time. + context.compressor_error = exc + context.fragments = [] @given( @@ -174,6 +222,12 @@ def step_make_metadata(context: Context, ratio: float, orig: int, comp: int) -> @when("I compress with skeleton_ratio {ratio:g}") def step_compress_ratio(context: Context, ratio: float) -> None: + # If model-level validation already caught an error during fragment + # construction (Given step), propagate it as the compress error. + if getattr(context, "compressor_error", None) is not None: + context.error = context.compressor_error + context.result = None + return try: context.result = context.service.compress( context.fragments, skeleton_ratio=ratio @@ -205,8 +259,11 @@ def step_compress_non_fragment_item(context: Context) -> None: try: context.result = context.service.compress( [ - ContextFragment( - fragment_id="ok", content="x", token_count=10, relevance=0.5 + _make_skel_fragment( + fragment_id="ok", + content="x", + token_count=10, + relevance_score=0.5, ), "not-a-fragment", ], # type: ignore[list-item] @@ -279,8 +336,8 @@ def step_all_returned(context: Context) -> None: def step_top_one(context: Context) -> None: assert context.result is not None assert len(context.result.fragments) == 1 - top_relevance = max(f.relevance for f in context.fragments) - assert context.result.fragments[0].relevance == top_relevance + top_relevance = max(f.relevance_score for f in context.fragments) + assert context.result.fragments[0].relevance_score == top_relevance @then("compressed tokens should be at most {limit:d}") @@ -306,13 +363,13 @@ def step_ordered_by_id(context: Context) -> None: @then("the first fragment should have relevance {rel:g}") def step_first_relevance(context: Context, rel: float) -> None: assert context.result is not None - assert context.result.fragments[0].relevance == rel + assert context.result.fragments[0].relevance_score == rel @then("the last fragment should have relevance {rel:g}") def step_last_relevance(context: Context, rel: float) -> None: assert context.result is not None - assert context.result.fragments[-1].relevance == rel + assert context.result.fragments[-1].relevance_score == rel @then("metadata original_tokens should be {expected:d}") @@ -337,9 +394,9 @@ def step_compressed_at_most(context: Context, limit: int) -> None: def step_all_decision_ids(context: Context) -> None: assert context.result is not None expected_ids = { - f.source_decision_id + f.metadata["source_decision_id"] for f in context.fragments - if f.source_decision_id is not None + if "source_decision_id" in f.metadata } actual_ids = set(context.result.metadata.source_decision_ids) assert expected_ids == actual_ids @@ -417,3 +474,67 @@ def step_plan_cli_dict(context: Context) -> None: assert skel["ratio"] == context.skel_meta.ratio assert skel["original_tokens"] == context.skel_meta.original_tokens assert skel["compressed_tokens"] == context.skel_meta.compressed_tokens + + +# --- _validate_fragments edge-case steps -------------------------------- + + +@given("a context fragment constructed with negative token_count") +def step_frag_negative_token(context: Context) -> None: + frag = ContextFragment.model_construct( + fragment_id="neg-tc", + uko_node="skeleton://neg-tc", + content="x", + token_count=-5, + relevance_score=0.5, + provenance=_SKEL_PROV, + ) + context.invalid_fragments = [frag] + + +@given("a context fragment constructed with relevance_score {score}") +def step_frag_bad_relevance(context: Context, score: str) -> None: + frag = ContextFragment.model_construct( + fragment_id="bad-rel", + uko_node="skeleton://bad-rel", + content="x", + token_count=10, + relevance_score=float(score), + provenance=_SKEL_PROV, + ) + context.invalid_fragments = [frag] + + +@given("a context fragment constructed with empty fragment_id") +def step_frag_empty_id(context: Context) -> None: + frag = ContextFragment.model_construct( + fragment_id="", + uko_node="skeleton://empty", + content="x", + token_count=10, + relevance_score=0.5, + provenance=_SKEL_PROV, + ) + context.invalid_fragments = [frag] + + +@when("I validate the invalid fragments") +def step_validate_invalid(context: Context) -> None: + try: + SkeletonCompressorService._validate_fragments(context.invalid_fragments) + context.validation_error = None + except (TypeError, ValueError) as exc: + context.validation_error = exc + + +@then('the compressor should raise a ValueError mentioning "{text}"') +def step_check_validation_error(context: Context, text: str) -> None: + assert context.validation_error is not None, ( + "Expected a ValueError but none was raised" + ) + assert isinstance(context.validation_error, ValueError), ( + f"Expected ValueError, got {type(context.validation_error).__name__}" + ) + assert text in str(context.validation_error), ( + f"Expected '{text}' in error message: {context.validation_error}" + ) diff --git a/features/steps/unified_context_models_steps.py b/features/steps/unified_context_models_steps.py new file mode 100644 index 000000000..5e2a03128 --- /dev/null +++ b/features/steps/unified_context_models_steps.py @@ -0,0 +1,277 @@ +"""Step definitions for features/unified_context_models.feature. + +Tests isinstance compatibility and construction of the unified +ContextFragment model hierarchy. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from behave import given, then, when +from behave.runner import Context +from pydantic import ValidationError + +from cleveragents.domain.models.acms.crp import ( + AssembledContext as CRPAssembledContext, +) +from cleveragents.domain.models.acms.crp import ( + ContextBudget as CRPContextBudget, +) +from cleveragents.domain.models.acms.crp import ( + ContextFragment as CRPContextFragment, +) +from cleveragents.domain.models.acms.crp import ( + FragmentProvenance as CRPFragmentProvenance, +) +from cleveragents.domain.models.core.context_fragment import ( + ContextBudget, + ContextFragment, + ContextPayload, + FragmentProvenance, +) + +# Default provenance for test fragments. +_DEFAULT_PROVENANCE = FragmentProvenance(resource_uri="test://unified") + + +# --------------------------------------------------------------------------- +# Given — Module availability +# --------------------------------------------------------------------------- + + +@given("the unified model modules are available") +def step_modules_available(context: Context) -> None: + pass + + +# --------------------------------------------------------------------------- +# Given — FragmentProvenance +# --------------------------------------------------------------------------- + + +@given('a core FragmentProvenance with resource_uri "{uri}"') +def step_core_provenance(context: Context, uri: str) -> None: + context.provenance = FragmentProvenance(resource_uri=uri) + + +@given('a core FragmentProvenance for strategy "{strategy}" with resource_uri "{uri}"') +def step_core_provenance_with_strategy( + context: Context, strategy: str, uri: str +) -> None: + context.provenance = FragmentProvenance(resource_uri=uri, strategy=strategy) + + +@given('a CRP FragmentProvenance with resource_uri "{uri}"') +def step_crp_provenance(context: Context, uri: str) -> None: + context.crp_provenance = CRPFragmentProvenance(resource_uri=uri) + + +# --------------------------------------------------------------------------- +# Given — ContextFragment +# --------------------------------------------------------------------------- + + +@given('a core ContextFragment with uko_node "{uko_node}" and content "{content}"') +def step_core_fragment(context: Context, uko_node: str, content: str) -> None: + context.unified_fragment = ContextFragment( + uko_node=uko_node, + content=content, + token_count=len(content), + provenance=_DEFAULT_PROVENANCE, + ) + + +@given("two core ContextFragments with identical fields") +def step_two_identical_fragments(context: Context) -> None: + fixed_time = datetime.now(UTC) + frag_a = ContextFragment( + fragment_id="FIXEDIDFOREQUALITYTEST12345", + uko_node="project://eq-test", + content="equal", + token_count=5, + provenance=_DEFAULT_PROVENANCE, + created_at=fixed_time, + ) + frag_b = ContextFragment( + fragment_id="FIXEDIDFOREQUALITYTEST12345", + uko_node="project://eq-test", + content="equal", + token_count=5, + provenance=_DEFAULT_PROVENANCE, + created_at=fixed_time, + ) + context.frag_a = frag_a + context.frag_b = frag_b + + +# --------------------------------------------------------------------------- +# Given — ContextBudget +# --------------------------------------------------------------------------- + + +@given("a core ContextBudget with max_tokens {max_tok:d} and reserved_tokens {res:d}") +def step_core_budget(context: Context, max_tok: int, res: int) -> None: + context.unified_budget = ContextBudget(max_tokens=max_tok, reserved_tokens=res) + + +# --------------------------------------------------------------------------- +# Given — ContextPayload +# --------------------------------------------------------------------------- + + +@given('a core ContextPayload with plan_id "{plan_id}"') +def step_core_payload(context: Context, plan_id: str) -> None: + context.unified_payload = ContextPayload(plan_id=plan_id) + + +# --------------------------------------------------------------------------- +# When — Mutation attempts +# --------------------------------------------------------------------------- + + +@when("I attempt to mutate the fragment uko_node") +def step_mutate_fragment(context: Context) -> None: + context.mutation_error = None + try: + context.unified_fragment.uko_node = "project://mutated" # type: ignore[misc] + except ValidationError as exc: + context.mutation_error = exc + + +@when("I attempt to mutate the budget max_tokens") +def step_mutate_budget(context: Context) -> None: + context.mutation_error = None + try: + context.unified_budget.max_tokens = 9999 # type: ignore[misc] + except ValidationError as exc: + context.mutation_error = exc + + +@when("I attempt to mutate the CRP provenance resource_uri") +def step_mutate_crp_provenance(context: Context) -> None: + context.mutation_error = None + try: + context.crp_provenance.resource_uri = "test://mutated" # type: ignore[misc] + except ValidationError as exc: + context.mutation_error = exc + + +@when("I create a core ContextBudget with reserved_tokens equal to max_tokens") +def step_create_budget_equal(context: Context) -> None: + context.strict_budget_error = None + try: + ContextBudget(max_tokens=100, reserved_tokens=100) + except ValidationError as exc: + context.strict_budget_error = exc + + +# --------------------------------------------------------------------------- +# Then — isinstance checks +# --------------------------------------------------------------------------- + + +@then("the provenance should be an instance of CRP FragmentProvenance") +def step_provenance_isinstance(context: Context) -> None: + assert isinstance(context.provenance, CRPFragmentProvenance), ( + f"Expected isinstance of CRP FragmentProvenance, got {type(context.provenance)}" + ) + + +@then("the fragment should be an instance of CRP ContextFragment") +def step_fragment_isinstance(context: Context) -> None: + assert isinstance(context.unified_fragment, CRPContextFragment), ( + f"Expected isinstance of CRP ContextFragment, " + f"got {type(context.unified_fragment)}" + ) + + +@then("the budget should be an instance of CRP ContextBudget") +def step_budget_isinstance(context: Context) -> None: + assert isinstance(context.unified_budget, CRPContextBudget), ( + f"Expected isinstance of CRP ContextBudget, got {type(context.unified_budget)}" + ) + + +@then("the payload should be an instance of CRP AssembledContext") +def step_payload_isinstance(context: Context) -> None: + assert isinstance(context.unified_payload, CRPAssembledContext), ( + f"Expected isinstance of CRP AssembledContext, " + f"got {type(context.unified_payload)}" + ) + + +# --------------------------------------------------------------------------- +# Then — Field value assertions +# --------------------------------------------------------------------------- + + +@then('the provenance resource_type should be "{value}"') +def step_provenance_resource_type(context: Context, value: str) -> None: + assert context.provenance.resource_type == value + + +@then('the provenance strategy should be "{value}"') +def step_provenance_strategy(context: Context, value: str) -> None: + assert context.provenance.strategy == value + + +@then('the unified fragment tier should be "{tier}"') +def step_fragment_tier(context: Context, tier: str) -> None: + assert context.unified_fragment.tier == tier + + +@then("the unified fragment fragment_id should be non-empty") +def step_fragment_id_nonempty(context: Context) -> None: + assert context.unified_fragment.fragment_id + assert len(context.unified_fragment.fragment_id) > 0 + + +@then("the unified fragment fragment_id should be {length:d} characters") +def step_fragment_id_length(context: Context, length: int) -> None: + fid = context.unified_fragment.fragment_id + assert len(fid) == length, ( + f"Expected fragment_id of {length} chars, got {len(fid)}: {fid!r}" + ) + + +@then("the unified fragment created_at should be a recent datetime") +def step_fragment_created_at_recent(context: Context) -> None: + created = context.unified_fragment.created_at + assert isinstance(created, datetime) + now = datetime.now(UTC) + assert now - created < timedelta(seconds=60) + + +@then("the unified budget available_tokens should be {tokens:d}") +def step_budget_available(context: Context, tokens: int) -> None: + assert context.unified_budget.available_tokens == tokens + + +# --------------------------------------------------------------------------- +# Then — Mutation error assertions +# --------------------------------------------------------------------------- + + +@then("a frozen mutation error should be raised") +def step_frozen_mutation_error(context: Context) -> None: + assert context.mutation_error is not None, ( + "Expected a frozen mutation error but none was raised" + ) + + +# --------------------------------------------------------------------------- +# Then — Equality assertions +# --------------------------------------------------------------------------- + + +@then("the two fragments should be equal") +def step_fragments_equal(context: Context) -> None: + assert context.frag_a == context.frag_b + + +@then("a validation error should be raised for strict budget") +def step_strict_budget_error(context: Context) -> None: + assert context.strict_budget_error is not None, ( + "Expected a validation error for reserved == max but none was raised" + ) diff --git a/features/unified_context_models.feature b/features/unified_context_models.feature new file mode 100644 index 000000000..c1fd4ff6a --- /dev/null +++ b/features/unified_context_models.feature @@ -0,0 +1,67 @@ +Feature: Unified context fragment model hierarchies + The core ContextFragment, FragmentProvenance, ContextBudget, and + ContextPayload types extend their CRP base counterparts, ensuring + isinstance compatibility across the model hierarchy. + + Background: + Given the unified model modules are available + + # --------------------------------------------------------------- + # isinstance compatibility + # --------------------------------------------------------------- + + Scenario: Core FragmentProvenance is instance of CRP FragmentProvenance + Given a core FragmentProvenance with resource_uri "project://test" + Then the provenance should be an instance of CRP FragmentProvenance + And the provenance resource_type should be "unknown" + And the provenance strategy should be empty + + Scenario: Core FragmentProvenance inherits CRP strategy field + Given a core FragmentProvenance for strategy "tier_retrieval" with resource_uri "project://test" + Then the provenance strategy should be "tier_retrieval" + And the provenance resource_type should be "unknown" + + Scenario: Core ContextFragment is instance of CRP ContextFragment + Given a core ContextFragment with uko_node "project://app/main.py" and content "hello" + Then the fragment should be an instance of CRP ContextFragment + And the unified fragment tier should be "warm" + And the unified fragment fragment_id should be non-empty + + Scenario: Core ContextFragment has ULID fragment_id by default + Given a core ContextFragment with uko_node "project://test" and content "test content" + Then the unified fragment fragment_id should be 26 characters + + Scenario: Core ContextFragment created_at is auto-populated + Given a core ContextFragment with uko_node "project://test" and content "test content" + Then the unified fragment created_at should be a recent datetime + + # --------------------------------------------------------------- + # Immutability (frozen) + # --------------------------------------------------------------- + + Scenario: Core ContextFragment is frozen + Given a core ContextFragment with uko_node "project://frozen" and content "frozen" + When I attempt to mutate the fragment uko_node + Then a frozen mutation error should be raised + + Scenario: Core ContextBudget is frozen + Given a core ContextBudget with max_tokens 4096 and reserved_tokens 512 + When I attempt to mutate the budget max_tokens + Then a frozen mutation error should be raised + + Scenario: CRP FragmentProvenance is frozen + Given a CRP FragmentProvenance with resource_uri "test://crp" + When I attempt to mutate the CRP provenance resource_uri + Then a frozen mutation error should be raised + + # --------------------------------------------------------------- + # Equality across hierarchy + # --------------------------------------------------------------- + + Scenario: Two core fragments with same data are equal + Given two core ContextFragments with identical fields + Then the two fragments should be equal + + Scenario: Core ContextBudget strict validation rejects equal reserved and max + When I create a core ContextBudget with reserved_tokens equal to max_tokens + Then a validation error should be raised for strict budget diff --git a/robot/helper_skeleton_compressor.py b/robot/helper_skeleton_compressor.py index ab68fc5c7..c26a27708 100644 --- a/robot/helper_skeleton_compressor.py +++ b/robot/helper_skeleton_compressor.py @@ -18,33 +18,61 @@ if _SRC not in sys.path: sys.path.insert(0, _SRC) from cleveragents.application.services.skeleton_compressor import ( # noqa: E402 - ContextFragment, SkeletonCompressorService, ) +from cleveragents.domain.models.core.context_fragment import ( # noqa: E402 + ContextFragment, + FragmentProvenance, +) + +# Default provenance for skeleton test fragments. +_SKEL_PROV = FragmentProvenance(resource_uri="skeleton://robot-test") + + +def _make_skel_fragment( + fragment_id: str, + content: str, + token_count: int, + relevance_score: float, + source_decision_id: str | None = None, +) -> ContextFragment: + """Create a ContextFragment suitable for skeleton compression tests.""" + metadata: dict[str, str] = {} + if source_decision_id is not None: + metadata["source_decision_id"] = source_decision_id + return ContextFragment( + fragment_id=fragment_id, + uko_node=f"skeleton://{fragment_id}", + content=content, + token_count=token_count, + relevance_score=relevance_score, + provenance=_SKEL_PROV, + metadata=metadata, + ) def _sample_fragments() -> list[ContextFragment]: """Build a repeatable set of sample fragments.""" return [ - ContextFragment( + _make_skel_fragment( fragment_id="frag-001", content="High relevance content " * 20, token_count=400, - relevance=0.9, + relevance_score=0.9, source_decision_id="01HXDECISION00000000000001", ), - ContextFragment( + _make_skel_fragment( fragment_id="frag-002", content="Medium relevance content " * 15, token_count=300, - relevance=0.6, + relevance_score=0.6, source_decision_id="01HXDECISION00000000000002", ), - ContextFragment( + _make_skel_fragment( fragment_id="frag-003", content="Low relevance content " * 15, token_count=300, - relevance=0.3, + relevance_score=0.3, source_decision_id="01HXDECISION00000000000003", ), ] @@ -114,11 +142,11 @@ def cmd_stable_ordering() -> None: """Verify fragments come out in deterministic order.""" svc = SkeletonCompressorService() frags = [ - ContextFragment( + _make_skel_fragment( fragment_id=f"frag-{chr(ord('c') - i)}", content="x", token_count=10, - relevance=0.5, + relevance_score=0.5, ) for i in range(3) ] diff --git a/robot/helper_unified_context_models.py b/robot/helper_unified_context_models.py new file mode 100644 index 000000000..db45c219b --- /dev/null +++ b/robot/helper_unified_context_models.py @@ -0,0 +1,178 @@ +"""Robot Framework helper for unified context model integration tests. + +Verifies isinstance compatibility across the CRP/core model hierarchy +and basic construction/immutability contracts. + +Usage: + python robot/helper_unified_context_models.py isinstance-check + python robot/helper_unified_context_models.py construction-check + python robot/helper_unified_context_models.py immutability-check +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from pathlib import Path + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.domain.models.acms.crp import ( # noqa: E402 + AssembledContext as CRPAssembledContext, +) +from cleveragents.domain.models.acms.crp import ( # noqa: E402 + ContextBudget as CRPContextBudget, +) +from cleveragents.domain.models.acms.crp import ( # noqa: E402 + ContextFragment as CRPContextFragment, +) +from cleveragents.domain.models.acms.crp import ( # noqa: E402 + FragmentProvenance as CRPFragmentProvenance, +) +from cleveragents.domain.models.core.context_fragment import ( # noqa: E402 + ContextBudget, + ContextFragment, + ContextPayload, + FragmentProvenance, +) + +_DEFAULT_PROV = FragmentProvenance(resource_uri="test://robot-unified") + + +def _cmd_isinstance_check() -> int: + """Verify isinstance compatibility for all four model types.""" + prov = FragmentProvenance(resource_uri="test://robot") + if not isinstance(prov, CRPFragmentProvenance): + print("FAIL: FragmentProvenance is not instance of CRP type") + return 1 + + frag = ContextFragment( + uko_node="test://robot", + content="hello", + token_count=5, + provenance=prov, + ) + if not isinstance(frag, CRPContextFragment): + print("FAIL: ContextFragment is not instance of CRP type") + return 1 + + budget = ContextBudget(max_tokens=4096, reserved_tokens=512) + if not isinstance(budget, CRPContextBudget): + print("FAIL: ContextBudget is not instance of CRP type") + return 1 + + payload = ContextPayload(plan_id="01JQTESTPN00000000000000AA") + if not isinstance(payload, CRPAssembledContext): + print("FAIL: ContextPayload is not instance of CRP AssembledContext") + return 1 + + print("unified-isinstance-ok") + return 0 + + +def _cmd_construction_check() -> int: + """Verify construction with inherited and extended fields.""" + prov = FragmentProvenance( + resource_uri="test://construct", + strategy="test_strategy", + resource_type="git-checkout", + ) + if prov.strategy != "test_strategy": + print(f"FAIL: expected strategy='test_strategy', got {prov.strategy!r}") + return 1 + if prov.resource_type != "git-checkout": + print( + f"FAIL: expected resource_type='git-checkout', got {prov.resource_type!r}" + ) + return 1 + + frag = ContextFragment( + uko_node="project://test", + content="constructed fragment", + token_count=20, + tier="hot", + provenance=prov, + ) + if frag.tier != "hot": + print(f"FAIL: expected tier='hot', got {frag.tier!r}") + return 1 + if not frag.fragment_id: + print("FAIL: fragment_id is empty") + return 1 + if len(frag.fragment_id) != 26: + print(f"FAIL: expected ULID (26 chars), got {len(frag.fragment_id)}") + return 1 + + budget = ContextBudget(max_tokens=8192, reserved_tokens=1024) + if budget.available_tokens != 7168: + print(f"FAIL: expected available=7168, got {budget.available_tokens}") + return 1 + + print("unified-construction-ok") + return 0 + + +def _cmd_immutability_check() -> int: + """Verify that frozen models reject mutation.""" + frag = ContextFragment( + uko_node="project://immut", + content="test", + token_count=4, + provenance=_DEFAULT_PROV, + ) + try: + frag.uko_node = "project://mutated" # type: ignore[misc] + print("FAIL: expected frozen error for ContextFragment mutation") + return 1 + except Exception: + pass + + budget = ContextBudget(max_tokens=4096, reserved_tokens=512) + try: + budget.max_tokens = 9999 # type: ignore[misc] + print("FAIL: expected frozen error for ContextBudget mutation") + return 1 + except Exception: + pass + + prov = CRPFragmentProvenance(resource_uri="test://crp") + try: + prov.resource_uri = "test://mutated" # type: ignore[misc] + print("FAIL: expected frozen error for CRP FragmentProvenance mutation") + return 1 + except Exception: + pass + + print("unified-immutability-ok") + return 0 + + +_COMMANDS: dict[str, Callable[[], int]] = { + "isinstance-check": _cmd_isinstance_check, + "construction-check": _cmd_construction_check, + "immutability-check": _cmd_immutability_check, +} + + +def main() -> int: + """Entry point called by Robot Framework ``Run Process``.""" + if len(sys.argv) < 2: + print( + "Usage: helper_unified_context_models.py " + "" + ) + return 1 + + command = sys.argv[1] + handler = _COMMANDS.get(command) + if handler is None: + print(f"Unknown command: {command}") + return 1 + + return handler() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/robot/unified_context_models.robot b/robot/unified_context_models.robot new file mode 100644 index 000000000..333913d1c --- /dev/null +++ b/robot/unified_context_models.robot @@ -0,0 +1,33 @@ +*** Settings *** +Documentation Integration tests for unified CRP/core context model hierarchy +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_unified_context_models.py + +*** Test Cases *** +Core Types Are Instances Of CRP Base Types + [Documentation] Verify that all four core types pass isinstance checks against CRP bases + ${result}= Run Process ${PYTHON} ${HELPER} isinstance-check cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} unified-isinstance-ok + +Unified Models Construct With Inherited And Extended Fields + [Documentation] Verify construction populates both CRP and core-specific fields + ${result}= Run Process ${PYTHON} ${HELPER} construction-check cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} unified-construction-ok + +Frozen Models Reject Mutation + [Documentation] Verify frozen configuration is inherited and enforced + ${result}= Run Process ${PYTHON} ${HELPER} immutability-check cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} unified-immutability-ok diff --git a/src/cleveragents/application/services/__init__.py b/src/cleveragents/application/services/__init__.py index 310f667ee..352bda17b 100644 --- a/src/cleveragents/application/services/__init__.py +++ b/src/cleveragents/application/services/__init__.py @@ -106,7 +106,6 @@ from cleveragents.application.services.session_service import ( ) from cleveragents.application.services.skeleton_compressor import ( CompressionResult, - ContextFragment, SkeletonCompressorService, ) from cleveragents.application.services.skill_registry_service import ( diff --git a/src/cleveragents/application/services/skeleton_compressor.py b/src/cleveragents/application/services/skeleton_compressor.py index 563b34eeb..8867e4873 100644 --- a/src/cleveragents/application/services/skeleton_compressor.py +++ b/src/cleveragents/application/services/skeleton_compressor.py @@ -9,7 +9,7 @@ by ``skeleton_ratio``: - **1.0** — maximum compression; only the single highest-relevance fragment is kept (with minimal content). -Fragments are sorted by ``relevance`` score in **descending** order +Fragments are sorted by ``relevance_score`` in **descending** order (highest first). A stable secondary sort on ``fragment_id`` ensures deterministic output for equal-relevance fragments. @@ -20,6 +20,7 @@ from __future__ import annotations from dataclasses import dataclass +from cleveragents.domain.models.core.context_fragment import ContextFragment from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata # --------------------------------------------------------------------------- @@ -27,27 +28,6 @@ from cleveragents.domain.models.core.skeleton_metadata import SkeletonMetadata # --------------------------------------------------------------------------- -@dataclass(frozen=True, slots=True) -class ContextFragment: - """A single fragment of context to be compressed. - - Attributes: - fragment_id: Stable identifier for this fragment. - content: The textual content of the fragment. - token_count: Number of tokens in ``content``. - relevance: A score in [0.0, 1.0] indicating how relevant - this fragment is (higher = more relevant). - source_decision_id: Optional decision ULID that produced - this fragment. - """ - - fragment_id: str - content: str - token_count: int - relevance: float - source_decision_id: str | None = None - - @dataclass(frozen=True, slots=True) class CompressionResult: """Output of a skeleton compression pass. @@ -86,7 +66,7 @@ class SkeletonCompressorService: Args: fragments: Context fragments to compress. Each must - have ``token_count >= 0`` and ``relevance`` in + have ``token_count >= 0`` and ``relevance_score`` in ``[0.0, 1.0]``. skeleton_ratio: Compression ratio in ``[0.0, 1.0]``. ``None`` falls back to ``DEFAULT_SKELETON_RATIO``. @@ -126,7 +106,7 @@ class SkeletonCompressorService: # -- stable sort: relevance desc, fragment_id asc ----------------- sorted_fragments = sorted( fragments, - key=lambda f: (-f.relevance, f.fragment_id), + key=lambda f: (-f.relevance_score, f.fragment_id), ) # -- select fragments within budget ------------------------------- @@ -135,7 +115,9 @@ class SkeletonCompressorService: compressed_tokens = sum(f.token_count for f in kept) source_ids = tuple( - f.source_decision_id for f in kept if f.source_decision_id is not None + f.metadata["source_decision_id"] + for f in kept + if "source_decision_id" in f.metadata ) metadata = SkeletonMetadata( @@ -172,10 +154,10 @@ class SkeletonCompressorService: raise ValueError( f"fragments[{idx}].token_count must be >= 0, got {frag.token_count}" ) - if frag.relevance < 0.0 or frag.relevance > 1.0: + if frag.relevance_score < 0.0 or frag.relevance_score > 1.0: raise ValueError( - f"fragments[{idx}].relevance must be in [0.0, 1.0], " - f"got {frag.relevance}" + f"fragments[{idx}].relevance_score must be in [0.0, 1.0], " + f"got {frag.relevance_score}" ) if not frag.fragment_id: raise ValueError(f"fragments[{idx}].fragment_id must be non-empty") diff --git a/src/cleveragents/cli/commands/project_context.py b/src/cleveragents/cli/commands/project_context.py index 932397cf0..051556a99 100644 --- a/src/cleveragents/cli/commands/project_context.py +++ b/src/cleveragents/cli/commands/project_context.py @@ -362,10 +362,10 @@ def _simulate_context_assembly( ) return AssembledContext( - fragments=fragments, + fragments=tuple(fragments), total_tokens=total_tokens, budget_used=min(budget_used, 1.0), - strategies_used=strategies_used, + strategies_used=tuple(strategies_used), context_hash=context_hash, preamble=( f"Simulated context for {project_name}" diff --git a/src/cleveragents/domain/models/acms/crp.py b/src/cleveragents/domain/models/acms/crp.py index 8be082926..fe603baf5 100644 --- a/src/cleveragents/domain/models/acms/crp.py +++ b/src/cleveragents/domain/models/acms/crp.py @@ -158,7 +158,7 @@ class DetailLevelMap(BaseModel): # --------------------------------------------------------------------------- -class FragmentProvenance(BaseModel): +class FragmentProvenance(BaseModel, frozen=True): """Provenance trace for a context fragment. Links a fragment back to the originating resource and location @@ -181,7 +181,6 @@ class FragmentProvenance(BaseModel): model_config = ConfigDict( str_strip_whitespace=True, - validate_assignment=True, ) @@ -190,7 +189,7 @@ class FragmentProvenance(BaseModel): # --------------------------------------------------------------------------- -class ContextBudget(BaseModel): +class ContextBudget(BaseModel, frozen=True): """Token budget for context assembly. Tracks the maximum token budget and any reserved portion, providing @@ -227,7 +226,6 @@ class ContextBudget(BaseModel): model_config = ConfigDict( str_strip_whitespace=True, - validate_assignment=True, ) @@ -236,7 +234,7 @@ class ContextBudget(BaseModel): # --------------------------------------------------------------------------- -class ContextFragment(BaseModel): +class ContextFragment(BaseModel, frozen=True): """A single piece of context assembled by a strategy. The atomic unit of context returned by strategies and consumed by @@ -281,7 +279,6 @@ class ContextFragment(BaseModel): model_config = ConfigDict( str_strip_whitespace=True, - validate_assignment=True, ) @@ -398,7 +395,7 @@ class ContextRequest(BaseModel): # --------------------------------------------------------------------------- -class AssembledContext(BaseModel): +class AssembledContext(BaseModel, frozen=True): """The fused, budget-respecting context payload. Output of a context assembly cycle -- the final payload delivered @@ -406,8 +403,8 @@ class AssembledContext(BaseModel): scoring, budget packing, and ordering. """ - fragments: list[ContextFragment] = Field( - default_factory=list, + fragments: tuple[ContextFragment, ...] = Field( + default=(), description="Ordered context fragments", ) total_tokens: int = Field( @@ -421,8 +418,8 @@ class AssembledContext(BaseModel): le=1.0, description="Fraction of budget consumed (0.0-1.0)", ) - strategies_used: list[str] = Field( - default_factory=list, + strategies_used: tuple[str, ...] = Field( + default=(), description="Which strategies contributed", ) context_hash: str = Field( @@ -441,5 +438,4 @@ class AssembledContext(BaseModel): model_config = ConfigDict( str_strip_whitespace=True, - validate_assignment=True, ) diff --git a/src/cleveragents/domain/models/core/context_fragment.py b/src/cleveragents/domain/models/core/context_fragment.py index ab208188c..b3987d1f1 100644 --- a/src/cleveragents/domain/models/core/context_fragment.py +++ b/src/cleveragents/domain/models/core/context_fragment.py @@ -3,7 +3,13 @@ Defines the core value objects for the Adaptive Context Management System (ACMS) context assembly pipeline: ``ContextFragment``, ``FragmentProvenance``, ``ContextBudget``, and ``ContextPayload``. All models are frozen Pydantic v2 -value objects. +value objects that **extend** the mutable CRP base types defined in +:mod:`cleveragents.domain.models.acms.crp`. + +The inheritance hierarchy ensures that every core ``ContextFragment`` is also +an ``isinstance`` of the CRP ``ContextFragment``, eliminating the previous +duplication and enabling strategies written against either interface to +interoperate seamlessly. Based on ``docs/specification.md`` ~line 25075 (ContextFragment) and ~line 25092 (AssembledContext). @@ -19,6 +25,18 @@ from typing import Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator from ulid import ULID +from cleveragents.domain.models.acms.crp import ( + AssembledContext as CRPAssembledContext, +) +from cleveragents.domain.models.acms.crp import ( + ContextBudget as CRPContextBudget, +) +from cleveragents.domain.models.acms.crp import ( + ContextFragment as CRPContextFragment, +) +from cleveragents.domain.models.acms.crp import ( + FragmentProvenance as CRPFragmentProvenance, +) from cleveragents.domain.models.core.plan import ULID_PATTERN # Maximum content length (characters). Enforced to prevent unbounded memory @@ -29,24 +47,16 @@ MAX_CONTENT_LENGTH: int = 1_000_000 # ~1 MB of text MAX_METADATA_ENTRIES: int = 64 -class FragmentProvenance(BaseModel, frozen=True): +class FragmentProvenance(CRPFragmentProvenance, frozen=True): """Provenance trace for a context fragment. - Records the originating resource and location so that fragments can be - traced back to their source for auditability and debugging. + Extends the CRP ``FragmentProvenance`` with an additional + ``resource_type`` field for richer auditability. Frozen so that + provenance records are immutable value objects. Based on ``docs/specification.md`` ~line 25088. """ - resource_uri: str = Field( - ..., - min_length=1, - description="URI of the originating resource (e.g. 'project://myapp/src/main.py')", - ) - location: str = Field( - default="", - description="Location within the resource (e.g. line range, section name)", - ) resource_type: str = Field( default="unknown", description=( @@ -55,20 +65,18 @@ class FragmentProvenance(BaseModel, frozen=True): ) -class ContextFragment(BaseModel, frozen=True): +class ContextFragment(CRPContextFragment, frozen=True): """A single piece of context assembled by the ACMS pipeline. + Extends the CRP ``ContextFragment`` with ``fragment_id``, ``tier``, + ``created_at``, and stricter validation. Frozen so that fragments + are immutable value objects. + Based on ``docs/specification.md`` ~line 25081. """ fragment_id: str = Field(default_factory=lambda: str(ULID())) - uko_node: str = Field( - ..., - min_length=1, - description="UKO URI of the source node", - ) - content: str = Field( ..., max_length=MAX_CONTENT_LENGTH, @@ -84,12 +92,6 @@ class ContextFragment(BaseModel, frozen=True): ), ) - token_count: int = Field( - ..., - ge=0, - description="Actual token count of content", - ) - relevance_score: float = Field( ge=0.0, le=1.0, @@ -132,12 +134,13 @@ class ContextFragment(BaseModel, frozen=True): return dict(v) -class ContextBudget(BaseModel, frozen=True): +class ContextBudget(CRPContextBudget, frozen=True): """Token budget for context assembly. - ``reserved_tokens`` must be strictly less than ``max_tokens``. The - effective minimum for ``max_tokens`` is therefore - ``reserved_tokens + 1``. + Extends the CRP ``ContextBudget`` with stricter defaults and + validation: ``reserved_tokens`` must be strictly less than + ``max_tokens``. The effective minimum for ``max_tokens`` is + therefore ``reserved_tokens + 1``. """ max_tokens: int = Field(ge=1, default=4096) @@ -153,15 +156,13 @@ class ContextBudget(BaseModel, frozen=True): raise ValueError(msg) return self - @property - def available_tokens(self) -> int: - """Return tokens available after reserving system-prompt space.""" - return self.max_tokens - self.reserved_tokens - -class ContextPayload(BaseModel, frozen=True): +class ContextPayload(CRPAssembledContext, frozen=True): """Assembled context payload ready for actor consumption. + Extends the CRP ``AssembledContext`` with ``payload_id``, ``plan_id``, + ``budget``, ``assembled_at``, and immutable tuple-based collections. + Corresponds to the spec's ``AssembledContext`` (~line 25098). Includes ``budget_used`` fraction, ``strategies_used`` list, ``context_hash`` for snapshot integrity, optional ``preamble``, and ``provenance_map``.