refactor(acms): unify ContextFragment model hierarchies by extending CRP base types
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 17s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 35s
CI / unit_tests (pull_request) Successful in 2m22s
CI / integration_tests (pull_request) Successful in 3m0s
CI / docker (pull_request) Successful in 39s
CI / coverage (pull_request) Successful in 4m21s
CI / lint (push) Successful in 14s
CI / quality (push) Successful in 16s
CI / build (push) Successful in 16s
CI / security (push) Successful in 30s
CI / typecheck (push) Successful in 36s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 3m38s
CI / docker (push) Successful in 39s
CI / integration_tests (push) Successful in 4m25s
CI / coverage (push) Successful in 4m19s
CI / benchmark-publish (push) Successful in 15m57s
CI / benchmark-regression (pull_request) Successful in 28m50s
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 17s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 35s
CI / unit_tests (pull_request) Successful in 2m22s
CI / integration_tests (pull_request) Successful in 3m0s
CI / docker (pull_request) Successful in 39s
CI / coverage (pull_request) Successful in 4m21s
CI / lint (push) Successful in 14s
CI / quality (push) Successful in 16s
CI / build (push) Successful in 16s
CI / security (push) Successful in 30s
CI / typecheck (push) Successful in 36s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 3m38s
CI / docker (push) Successful in 39s
CI / integration_tests (push) Successful in 4m25s
CI / coverage (push) Successful in 4m19s
CI / benchmark-publish (push) Successful in 15m57s
CI / benchmark-regression (pull_request) Successful in 28m50s
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
This commit was merged in pull request #598.
This commit is contained in:
@@ -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)
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
@@ -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 "
|
||||
"<isinstance-check|construction-check|immutability-check>"
|
||||
)
|
||||
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())
|
||||
@@ -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
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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``.
|
||||
|
||||
Reference in New Issue
Block a user