From 3c014a9565770ede68427fb2a4638606bdcb59c3 Mon Sep 17 00:00:00 2001 From: Hamza Khyari Date: Fri, 13 Mar 2026 18:21:04 +0000 Subject: [PATCH] feat(acms): implement UKO Layer 2 Paradigm Vocabularies (uko-oo, uko-func, uko-proc) --- CHANGELOG.md | 13 + benchmarks/bench_detail_level_map.py | 142 ++++ docs/ontology/uko.ttl | 59 +- .../uko_layer2_paradigm_vocabularies.md | 171 ++++ .../uko_layer2_paradigm_vocabularies.feature | 766 ++++++++++++++++++ features/steps/_uko_l2_test_helpers.py | 56 ++ features/steps/uko_l2_coverage_ttl_steps.py | 148 ++++ features/steps/uko_l2_detail_level_steps.py | 386 +++++++++ features/steps/uko_l2_vocab_registry_steps.py | 123 +++ features/steps/uko_l2_vocabulary_steps.py | 427 ++++++++++ features/uko_ontology.feature | 2 +- robot/helper_uko_layer2_paradigm.py | 239 ++++++ src/cleveragents/acms/__init__.py | 31 + src/cleveragents/acms/uko/__init__.py | 56 ++ .../acms/uko/detail_level_maps.py | 296 +++++++ src/cleveragents/acms/uko/vocabularies.py | 486 +++++++++++ .../acms/uko/vocabulary_registry.py | 145 ++++ .../domain/models/acms/__init__.py | 9 + src/cleveragents/domain/models/acms/crp.py | 176 ++-- .../domain/models/acms/detail_level.py | 293 +++++++ 20 files changed, 3894 insertions(+), 130 deletions(-) create mode 100644 benchmarks/bench_detail_level_map.py create mode 100644 docs/reference/uko_layer2_paradigm_vocabularies.md create mode 100644 features/acms/uko_layer2_paradigm_vocabularies.feature create mode 100644 features/steps/_uko_l2_test_helpers.py create mode 100644 features/steps/uko_l2_coverage_ttl_steps.py create mode 100644 features/steps/uko_l2_detail_level_steps.py create mode 100644 features/steps/uko_l2_vocab_registry_steps.py create mode 100644 features/steps/uko_l2_vocabulary_steps.py create mode 100644 robot/helper_uko_layer2_paradigm.py create mode 100644 src/cleveragents/acms/__init__.py create mode 100644 src/cleveragents/acms/uko/__init__.py create mode 100644 src/cleveragents/acms/uko/detail_level_maps.py create mode 100644 src/cleveragents/acms/uko/vocabularies.py create mode 100644 src/cleveragents/acms/uko/vocabulary_registry.py create mode 100644 src/cleveragents/domain/models/acms/detail_level.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dda945547..e2975c5bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,19 @@ real DI path. Includes Robot Framework smoke tests and ASV benchmarks. Tests are intentionally failing (``@tdd_expected_fail``) until the bug fix for #570 is applied. (#630) +- Implemented UKO Layer 2 paradigm vocabulary specializations: Object-Oriented + (`uko-oo:`), Functional (`uko-func:`), and Procedural (`uko-proc:`). Added + OWL/Turtle class and property definitions for all three paradigms in + `docs/ontology/uko.ttl`. Implemented `DetailLevelMapBuilder` with insertion + and integer reassignment logic for extending parent DetailLevelMaps. + `ParadigmVocabulary`, `VocabularyClass`, `VocabularyProperty`, and + `VocabularyRegistry` frozen Pydantic models provide the Python API. Includes + Behave BDD tests (60+ scenarios), Robot Framework integration helper, ASV + benchmarks for DetailLevelMap operations, and reference documentation. + **Breaking:** `DetailLevelMap.effective_levels()` now returns + `MappingProxyType[str, int]` (read-only) instead of `dict[str, int]`; + callers that mutated the returned mapping must copy to a `dict` first. + (#575, PR #657) - Added `RepoIndexingService` for repository file indexing with incremental refresh, extension-based language detection, SHA-256 content hashing, and token estimation. Supports policy enforcement via include/exclude globs, diff --git a/benchmarks/bench_detail_level_map.py b/benchmarks/bench_detail_level_map.py new file mode 100644 index 000000000..941589547 --- /dev/null +++ b/benchmarks/bench_detail_level_map.py @@ -0,0 +1,142 @@ +"""ASV benchmarks for DetailLevelMap operations. + +Measures the performance of DetailLevelMap operations including: +- Named level resolution (string lookups) +- Integer depth resolution (clamping) +- Effective level map computation (inheritance merge) +- DetailLevelMapBuilder insertions and build +- VocabularyRegistry lookups + +Based on specification.md DetailDepth and DetailLevelMap sections. +""" + +from __future__ import annotations + +import sys +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.acms.uko.detail_level_maps import ( + CODE_DETAIL_LEVEL_MAP, + OO_DETAIL_LEVEL_MAP, + DetailLevelMapBuilder, + build_effective_map, +) +from cleveragents.acms.uko.vocabularies import ( + get_func_vocabulary, + get_oo_vocabulary, + get_proc_vocabulary, +) +from cleveragents.acms.uko.vocabulary_registry import VocabularyRegistry + + +class DetailLevelMapResolutionSuite: + """Benchmark DetailLevelMap.resolve() for named and integer depths.""" + + timeout = 60 + + def setup(self) -> None: + """Prepare maps for benchmarking.""" + self.code_map = CODE_DETAIL_LEVEL_MAP + self.oo_map = OO_DETAIL_LEVEL_MAP + + def time_resolve_named_level_code(self) -> None: + """Benchmark resolving a named level in the code map.""" + self.code_map.resolve("SIGNATURES") + + def time_resolve_named_level_oo(self) -> None: + """Benchmark resolving a named level in the OO map.""" + self.oo_map.resolve("CLASS_HIERARCHY") + + def time_resolve_integer_depth(self) -> None: + """Benchmark resolving an integer depth.""" + self.code_map.resolve(4) + + def time_resolve_integer_clamped(self) -> None: + """Benchmark resolving an integer that exceeds max_depth.""" + self.code_map.resolve(100) + + def time_resolve_inherited_level(self) -> None: + """Benchmark resolving a level inherited from parent.""" + self.oo_map.resolve("MODULE_LISTING") + + def time_effective_levels_code(self) -> None: + """Benchmark computing effective levels for code map.""" + self.code_map.effective_levels() + + def time_effective_levels_oo(self) -> None: + """Benchmark computing effective levels for OO map (with parent).""" + self.oo_map.effective_levels() + + +class DetailLevelMapBuilderSuite: + """Benchmark DetailLevelMapBuilder operations.""" + + timeout = 60 + + def setup(self) -> None: + """Prepare parent map.""" + self.code_map = CODE_DETAIL_LEVEL_MAP + + def time_build_with_single_insertion(self) -> None: + """Benchmark building a map with one insertion.""" + builder = DetailLevelMapBuilder(self.code_map, "bench:") + builder.insert_after("MEMBER_LISTING", "BENCH_LEVEL") + builder.build() + + def time_build_with_two_insertions(self) -> None: + """Benchmark building a map with two insertions (OO pattern).""" + builder = DetailLevelMapBuilder(self.code_map, "bench:") + builder.insert_after("MEMBER_LISTING", "BENCH_A") + builder.insert_after("SIGNATURES_WITH_DOCS", "BENCH_B") + builder.build() + + def time_build_effective_map_no_insertions(self) -> None: + """Benchmark build_effective_map with empty insertions.""" + build_effective_map(self.code_map, []) + + def time_build_effective_map_with_insertions(self) -> None: + """Benchmark build_effective_map with two insertions.""" + build_effective_map( + self.code_map, + [("MEMBER_LISTING", "X"), ("SIGNATURES_WITH_DOCS", "Y")], + ) + + +class VocabularyRegistrySuite: + """Benchmark VocabularyRegistry operations.""" + + timeout = 60 + + def setup(self) -> None: + """Build and populate the registry.""" + self.registry = VocabularyRegistry( + vocabularies=( + get_oo_vocabulary(), + get_func_vocabulary(), + get_proc_vocabulary(), + ) + ) + + def time_get_by_prefix(self) -> None: + """Benchmark looking up vocabulary by prefix.""" + self.registry.get_by_prefix("uko-oo:") + + def time_get_by_iri(self) -> None: + """Benchmark looking up vocabulary by IRI.""" + self.registry.get_by_iri("https://cleveragents.ai/ontology/uko/oo#") + + def time_list_prefixes(self) -> None: + """Benchmark listing all prefixes.""" + self.registry.list_prefixes() + + def time_list_all(self) -> None: + """Benchmark listing all vocabularies.""" + self.registry.list_all() + + def time_contains_check(self) -> None: + """Benchmark __contains__ check.""" + _ = "uko-oo:" in self.registry diff --git a/docs/ontology/uko.ttl b/docs/ontology/uko.ttl index e379cc320..cb291739c 100644 --- a/docs/ontology/uko.ttl +++ b/docs/ontology/uko.ttl @@ -4,7 +4,8 @@ @prefix uko-data: . @prefix uko-infra: . @prefix uko-oo: . -@prefix uko-py: . +@prefix uko-func: . +@prefix uko-proc: . @prefix rdf: . @prefix rdfs: . @prefix owl: . @@ -551,6 +552,10 @@ uko-infra:exposes a owl:ObjectProperty ; # Layer 2: Object-Oriented Paradigm (uko-oo:) # =========================================================================== +# Design decision: uko-oo:Class inherits from both TypeDefinition (an +# Atom descendant) and Container. This is intentional OWL multiple +# inheritance per specification.md ~line 44340. Atom and Container +# are NOT declared owl:disjointWith, so individuals are satisfiable. uko-oo:Class a owl:Class ; rdfs:subClassOf uko-code:TypeDefinition , uko:Container ; rdfs:label "Class" ; @@ -585,10 +590,58 @@ uko-oo:implements a owl:ObjectProperty ; rdfs:range uko-oo:Interface ; rdfs:label "implements" . +# =========================================================================== +# Layer 2: Functional Paradigm (uko-func:) +# =========================================================================== + +uko-func:PureFunction a owl:Class ; + rdfs:subClassOf uko-code:Callable ; + rdfs:label "PureFunction" ; + rdfs:comment "A pure function with no side effects." . + +uko-func:TypeClass a owl:Class ; + rdfs:subClassOf uko-code:TypeDefinition , uko:Boundary ; + rdfs:label "TypeClass" ; + rdfs:comment "A type class defining a set of operations for types." . + +uko-func:Monad a owl:Class ; + rdfs:subClassOf uko-code:TypeDefinition ; + rdfs:label "Monad" ; + rdfs:comment "A monadic type encapsulating computation context." . + +# =========================================================================== +# Layer 2: Procedural Paradigm (uko-proc:) +# =========================================================================== + +uko-proc:ProceduralFunction a owl:Class ; + rdfs:subClassOf uko-code:Callable ; + rdfs:label "ProceduralFunction" ; + rdfs:comment "A procedural function declaration." . + +uko-proc:GlobalVariable a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "GlobalVariable" ; + rdfs:comment "A global variable declaration." . + +uko-proc:HeaderFile a owl:Class ; + rdfs:subClassOf uko:Container ; + rdfs:label "HeaderFile" ; + rdfs:comment "A header file defining exported declarations." . + +uko-proc:StructDefinition a owl:Class ; + rdfs:subClassOf uko-code:TypeDefinition ; + rdfs:label "StructDefinition" ; + rdfs:comment "A C-style struct type definition." . + +uko-proc:Macro a owl:Class ; + rdfs:subClassOf uko:Atom ; + rdfs:label "Macro" ; + rdfs:comment "A preprocessor macro definition." . + # =========================================================================== # Layer 3: Technology-Specific Specializations # =========================================================================== # Layer 3 extends Layer 2 (or Layer 1) with technology-specific refinements. # Per the specification, Layer 3 is defined via DetailLevelMap insertions -# rather than new OWL classes. The uko-py: prefix is reserved for -# Python-specific extensions when DetailLevelMap support is added. +# rather than new OWL classes. Technology-specific prefixes (e.g. uko-py:) +# will be declared when their DetailLevelMap support is added. diff --git a/docs/reference/uko_layer2_paradigm_vocabularies.md b/docs/reference/uko_layer2_paradigm_vocabularies.md new file mode 100644 index 000000000..17cb02c2e --- /dev/null +++ b/docs/reference/uko_layer2_paradigm_vocabularies.md @@ -0,0 +1,171 @@ +# UKO Layer 2 Paradigm Vocabularies + +## Overview + +Layer 2 of the Unified Knowledge Ontology (UKO) refines the Layer 1 +`uko-code:` domain with paradigm-specific specializations for three +programming paradigms: + +- **Object-Oriented** (`uko-oo:`) — classes, interfaces, methods, attributes +- **Functional** (`uko-func:`) — pure functions, type classes, monads +- **Procedural** (`uko-proc:`) — procedural functions, globals, headers, structs, macros + +Each paradigm defines OWL classes that subclass Layer 1 or Layer 0 concepts, +and extends the parent `uko-code:` DetailLevelMap with paradigm-specific +named levels. + +Based on `docs/specification.md` ~lines 42333-42422, 24923-25027. + +## Namespace Prefixes + +| Prefix | IRI | Layer | +|--------|-----|-------| +| `uko-oo:` | `https://cleveragents.ai/ontology/uko/oo#` | 2 | +| `uko-func:` | `https://cleveragents.ai/ontology/uko/func#` | 2 | +| `uko-proc:` | `https://cleveragents.ai/ontology/uko/proc#` | 2 | + +## Object-Oriented Vocabulary (`uko-oo:`) + +### Classes + +| Class | Superclasses | Description | +|-------|-------------|-------------| +| `uko-oo:Class` | `uko-code:TypeDefinition`, `uko:Container` | An OO class that contains methods and attributes. | +| `uko-oo:Interface` | `uko-code:TypeDefinition`, `uko:Boundary` | An interface or abstract base class. | +| `uko-oo:Method` | `uko-code:Callable` | A method defined within a class. | +| `uko-oo:Attribute` | `uko:Atom` | A class or instance attribute. | + +### Properties + +| Property | Domain | Range | subPropertyOf | +|----------|--------|-------|---------------| +| `uko-oo:inheritsFrom` | `uko-oo:Class` | `uko-oo:Class` | `uko:dependsOn` | +| `uko-oo:implements` | `uko-oo:Class` | `uko-oo:Interface` | `uko:dependsOn` | + +### DetailLevelMap Insertions + +The OO map inserts two levels into the `uko-code:` base map: + +| Depth | Name | Origin | Content | +|-------|------|--------|---------| +| 3 | `CLASS_HIERARCHY` | **uko-oo:** | Inheritance chains and interface implementations | +| 7 | `VISIBILITY_ANNOTATED` | **uko-oo:** | Public/protected/private modifiers | + +The full effective map with reassigned depths is documented in +`docs/specification.md` ~lines 24991-25004. + +## Functional Vocabulary (`uko-func:`) + +### Classes + +| Class | Superclasses | Description | +|-------|-------------|-------------| +| `uko-func:PureFunction` | `uko-code:Callable` | A pure function with no side effects. | +| `uko-func:TypeClass` | `uko-code:TypeDefinition`, `uko:Boundary` | A type class defining a set of operations for types. | +| `uko-func:Monad` | `uko-code:TypeDefinition` | A monadic type encapsulating computation context. | + +### DetailLevelMap + +Uses the same named levels as `uko-code:` without insertions. + +## Procedural Vocabulary (`uko-proc:`) + +### Classes + +| Class | Superclasses | Description | +|-------|-------------|-------------| +| `uko-proc:ProceduralFunction` | `uko-code:Callable` | A procedural function declaration. | +| `uko-proc:GlobalVariable` | `uko:Atom` | A global variable declaration. | +| `uko-proc:HeaderFile` | `uko:Container` | A header file defining exported declarations. | +| `uko-proc:StructDefinition` | `uko-code:TypeDefinition` | A C-style struct type definition. | +| `uko-proc:Macro` | `uko:Atom` | A preprocessor macro definition. | + +### DetailLevelMap + +Uses the same named levels as `uko-code:` without insertions. + +## DetailLevelMap Inheritance + +The inheritance chain resolves named levels by walking up the map hierarchy: + +``` +Layer 3 (uko-py:) -> Layer 2 (uko-oo:) -> Layer 1 (uko-code:) -> Layer 0 (uko:) +``` + +When a named level is requested, the system looks up the name in the most +specific map first, then walks up to parent maps until a match is found. +Integer depths are used directly (clamped to `max_depth`). + +### Insertion Mechanics + +When a child map inserts a new named level (e.g., `CLASS_HIERARCHY` after +`MEMBER_LISTING`), all subsequent levels shift upward by one to maintain +consecutive integer numbering. + +## Python API + +### `DetailLevelMapBuilder` + +```python +from cleveragents.acms.uko.detail_level_maps import ( + CODE_DETAIL_LEVEL_MAP, + DetailLevelMapBuilder, +) + +builder = DetailLevelMapBuilder(CODE_DETAIL_LEVEL_MAP, "uko-oo:") +builder.insert_after("MEMBER_LISTING", "CLASS_HIERARCHY") +builder.insert_after("SIGNATURES_WITH_DOCS", "VISIBILITY_ANNOTATED") +oo_map = builder.build() + +assert oo_map.resolve("CLASS_HIERARCHY") == 3 +assert oo_map.resolve("FULL_SOURCE") == 11 +``` + +### `VocabularyRegistry` + +```python +from cleveragents.acms.uko.vocabularies import ( + get_oo_vocabulary, + get_func_vocabulary, + get_proc_vocabulary, +) +from cleveragents.acms.uko.vocabulary_registry import VocabularyRegistry + +registry = VocabularyRegistry( + vocabularies=( + get_oo_vocabulary(), + get_func_vocabulary(), + get_proc_vocabulary(), + ) +) +oo = registry.get_by_prefix("uko-oo:") +assert oo is not None +assert oo.get_class("Class") is not None +``` + +## Parent-Layer Dependencies + +Layer 2 vocabularies reference these Layer 1 URIs via `rdfs:subClassOf`: + +- `https://cleveragents.ai/ontology/uko/code#TypeDefinition` +- `https://cleveragents.ai/ontology/uko/code#Callable` + +Layer 2 vocabularies also reference these Layer 0 URIs via +`rdfs:subClassOf` or `rdfs:subPropertyOf`: + +- `https://cleveragents.ai/ontology/uko#Container` — superclass of `uko-oo:Class`, `uko-proc:HeaderFile` +- `https://cleveragents.ai/ontology/uko#Atom` — superclass of `uko-oo:Attribute`, `uko-proc:GlobalVariable`, `uko-proc:Macro` +- `https://cleveragents.ai/ontology/uko#Boundary` — superclass of `uko-oo:Interface`, `uko-func:TypeClass` +- `https://cleveragents.ai/ontology/uko#dependsOn` — superproperty of `uko-oo:inheritsFrom`, `uko-oo:implements` + +All URIs are defined in `docs/ontology/uko.ttl` and will be fully wired +when the Layer 1 Python module (`uko-code:`) is implemented. + +## Limitations + +- Layer 1 (`uko-code:`) Python module is not yet implemented; the Python + code works standalone with the `DetailLevelMap` from `crp.py`. +- Turtle files are validated by the `UKOLoader` but not by a full + OWL reasoner. +- The procedural vocabulary classes are inferred from the spec's rendering + tables rather than explicit Turtle definitions. diff --git a/features/acms/uko_layer2_paradigm_vocabularies.feature b/features/acms/uko_layer2_paradigm_vocabularies.feature new file mode 100644 index 000000000..34d8e2bdf --- /dev/null +++ b/features/acms/uko_layer2_paradigm_vocabularies.feature @@ -0,0 +1,766 @@ +@phase2 @acms @uko_layer2 +Feature: UKO Layer 2 Paradigm Vocabularies + As a CleverAgents developer + I want paradigm-specific UKO vocabulary specializations + So that OO, functional, and procedural code concepts have precise ontology types + + # --------------------------------------------------------------------------- + # VocabularyClass — Construction and Validation + # --------------------------------------------------------------------------- + + @vocab_class + Scenario: Create a VocabularyClass for uko_l2 + Given a simple vocabulary class with uri "https://cleveragents.ai/ontology/uko/oo#Class" and label "Class" for uko_l2 + Then the vocabulary class uri should be "https://cleveragents.ai/ontology/uko/oo#Class" for uko_l2 + And the vocabulary class label should be "Class" for uko_l2 + And the vocabulary class comment should be empty for uko_l2 + And the vocabulary class parent_uris should be empty for uko_l2 + + @vocab_class + Scenario: Create a VocabularyClass with parents for uko_l2 + Given a vocabulary class with uri "https://cleveragents.ai/ontology/uko/oo#Class" and label "Class" and parents "https://cleveragents.ai/ontology/uko/code#TypeDefinition,https://cleveragents.ai/ontology/uko#Container" for uko_l2 + Then the vocabulary class should have 2 parent URIs for uko_l2 + And the vocabulary class parent 0 should be "https://cleveragents.ai/ontology/uko/code#TypeDefinition" for uko_l2 + + @vocab_class @validation + Scenario: Reject VocabularyClass with empty URI for uko_l2 + When I create a vocabulary class with empty uri for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_class @validation + Scenario: Reject VocabularyClass with whitespace-only URI for uko_l2 + When I create a vocabulary class with whitespace uri for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_class @validation + Scenario: Reject VocabularyClass with empty label for uko_l2 + When I create a vocabulary class with empty label for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_class @vocab_frozen + Scenario: VocabularyClass is immutable for uko_l2 + Given a simple vocabulary class with uri "https://cleveragents.ai/ontology/uko/oo#Class" and label "Class" for uko_l2 + When I try to mutate the vocabulary class uri for uko_l2 + Then a frozen mutation error should be raised for uko_l2 + + # --------------------------------------------------------------------------- + # VocabularyProperty — Construction and Validation + # --------------------------------------------------------------------------- + + @vocab_property + Scenario: Create a VocabularyProperty for uko_l2 + Given a basic vocabulary property with uri "https://cleveragents.ai/ontology/uko/oo#inheritsFrom" and label "inheritsFrom" and domain "https://cleveragents.ai/ontology/uko/oo#Class" and range "https://cleveragents.ai/ontology/uko/oo#Class" for uko_l2 + Then the vocabulary property uri should be "https://cleveragents.ai/ontology/uko/oo#inheritsFrom" for uko_l2 + And the vocabulary property label should be "inheritsFrom" for uko_l2 + And the vocabulary property sub_property_of should be empty for uko_l2 + + @vocab_property + Scenario: Create a VocabularyProperty with subPropertyOf for uko_l2 + Given a vocabulary property with uri "https://cleveragents.ai/ontology/uko/oo#inheritsFrom" and label "inheritsFrom" and domain "https://cleveragents.ai/ontology/uko/oo#Class" and range "https://cleveragents.ai/ontology/uko/oo#Class" and sub_property_of "https://cleveragents.ai/ontology/uko#dependsOn" for uko_l2 + Then the vocabulary property sub_property_of should be "https://cleveragents.ai/ontology/uko#dependsOn" for uko_l2 + + @vocab_property @validation + Scenario: Reject VocabularyProperty with empty URI for uko_l2 + When I create a vocabulary property with empty uri for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_property @vocab_frozen + Scenario: VocabularyProperty is immutable for uko_l2 + Given a basic vocabulary property with uri "https://cleveragents.ai/ontology/uko/oo#inheritsFrom" and label "inheritsFrom" and domain "https://cleveragents.ai/ontology/uko/oo#Class" and range "https://cleveragents.ai/ontology/uko/oo#Class" for uko_l2 + When I try to mutate the vocabulary property uri for uko_l2 + Then a frozen mutation error should be raised for uko_l2 + + # --------------------------------------------------------------------------- + # Object-Oriented Vocabulary (uko-oo:) + # --------------------------------------------------------------------------- + + @uko_oo + Scenario: uko-oo vocabulary has all required classes for uko_l2 + Given the uko-oo vocabulary for uko_l2 + Then the vocabulary should have prefix "uko-oo:" for uko_l2 + And the vocabulary should have IRI "https://cleveragents.ai/ontology/uko/oo#" for uko_l2 + And the vocabulary should have layer 2 for uko_l2 + And the vocabulary should have 4 classes for uko_l2 + And the vocabulary should contain class "Class" for uko_l2 + And the vocabulary should contain class "Interface" for uko_l2 + And the vocabulary should contain class "Method" for uko_l2 + And the vocabulary should contain class "Attribute" for uko_l2 + + @uko_oo + Scenario: uko-oo vocabulary has all required properties for uko_l2 + Given the uko-oo vocabulary for uko_l2 + Then the vocabulary should have 2 properties for uko_l2 + And the vocabulary should contain property "inheritsFrom" for uko_l2 + And the vocabulary should contain property "implements" for uko_l2 + + @uko_oo + Scenario: uko-oo Class has correct parent URIs for uko_l2 + Given the uko-oo vocabulary for uko_l2 + When I look up class "Class" for uko_l2 + Then the class should have parent "https://cleveragents.ai/ontology/uko/code#TypeDefinition" for uko_l2 + And the class should have parent "https://cleveragents.ai/ontology/uko#Container" for uko_l2 + + @uko_oo + Scenario: uko-oo Interface has correct parent URIs for uko_l2 + Given the uko-oo vocabulary for uko_l2 + When I look up class "Interface" for uko_l2 + Then the class should have parent "https://cleveragents.ai/ontology/uko/code#TypeDefinition" for uko_l2 + And the class should have parent "https://cleveragents.ai/ontology/uko#Boundary" for uko_l2 + + @uko_oo + Scenario: uko-oo Method has correct parent URI for uko_l2 + Given the uko-oo vocabulary for uko_l2 + When I look up class "Method" for uko_l2 + Then the class should have parent "https://cleveragents.ai/ontology/uko/code#Callable" for uko_l2 + + @uko_oo + Scenario: uko-oo Attribute has correct parent URI for uko_l2 + Given the uko-oo vocabulary for uko_l2 + When I look up class "Attribute" for uko_l2 + Then the class should have parent "https://cleveragents.ai/ontology/uko#Atom" for uko_l2 + + @uko_oo + Scenario: uko-oo inheritsFrom property has correct domain and range for uko_l2 + Given the uko-oo vocabulary for uko_l2 + When I look up property "inheritsFrom" for uko_l2 + Then the property domain should be "https://cleveragents.ai/ontology/uko/oo#Class" for uko_l2 + And the property range should be "https://cleveragents.ai/ontology/uko/oo#Class" for uko_l2 + And the property sub_property_of should be "https://cleveragents.ai/ontology/uko#dependsOn" for uko_l2 + + @uko_oo + Scenario: uko-oo implements property has correct domain and range for uko_l2 + Given the uko-oo vocabulary for uko_l2 + When I look up property "implements" for uko_l2 + Then the property domain should be "https://cleveragents.ai/ontology/uko/oo#Class" for uko_l2 + And the property range should be "https://cleveragents.ai/ontology/uko/oo#Interface" for uko_l2 + + # --------------------------------------------------------------------------- + # Functional Vocabulary (uko-func:) + # --------------------------------------------------------------------------- + + @uko_func + Scenario: uko-func vocabulary has all required classes for uko_l2 + Given the uko-func vocabulary for uko_l2 + Then the vocabulary should have prefix "uko-func:" for uko_l2 + And the vocabulary should have IRI "https://cleveragents.ai/ontology/uko/func#" for uko_l2 + And the vocabulary should have layer 2 for uko_l2 + And the vocabulary should have 3 classes for uko_l2 + And the vocabulary should contain class "PureFunction" for uko_l2 + And the vocabulary should contain class "TypeClass" for uko_l2 + And the vocabulary should contain class "Monad" for uko_l2 + + @uko_func + Scenario: uko-func vocabulary has no properties for uko_l2 + Given the uko-func vocabulary for uko_l2 + Then the vocabulary should have 0 properties for uko_l2 + + @uko_func + Scenario: uko-func PureFunction has correct parent URI for uko_l2 + Given the uko-func vocabulary for uko_l2 + When I look up class "PureFunction" for uko_l2 + Then the class should have parent "https://cleveragents.ai/ontology/uko/code#Callable" for uko_l2 + + @uko_func + Scenario: uko-func TypeClass has correct parent URIs for uko_l2 + Given the uko-func vocabulary for uko_l2 + When I look up class "TypeClass" for uko_l2 + Then the class should have parent "https://cleveragents.ai/ontology/uko/code#TypeDefinition" for uko_l2 + And the class should have parent "https://cleveragents.ai/ontology/uko#Boundary" for uko_l2 + + @uko_func + Scenario: uko-func Monad has correct parent URI for uko_l2 + Given the uko-func vocabulary for uko_l2 + When I look up class "Monad" for uko_l2 + Then the class should have parent "https://cleveragents.ai/ontology/uko/code#TypeDefinition" for uko_l2 + + # --------------------------------------------------------------------------- + # Procedural Vocabulary (uko-proc:) + # --------------------------------------------------------------------------- + + @uko_proc + Scenario: uko-proc vocabulary has all required classes for uko_l2 + Given the uko-proc vocabulary for uko_l2 + Then the vocabulary should have prefix "uko-proc:" for uko_l2 + And the vocabulary should have IRI "https://cleveragents.ai/ontology/uko/proc#" for uko_l2 + And the vocabulary should have layer 2 for uko_l2 + And the vocabulary should have 5 classes for uko_l2 + And the vocabulary should contain class "ProceduralFunction" for uko_l2 + And the vocabulary should contain class "GlobalVariable" for uko_l2 + And the vocabulary should contain class "HeaderFile" for uko_l2 + And the vocabulary should contain class "StructDefinition" for uko_l2 + And the vocabulary should contain class "Macro" for uko_l2 + + @uko_proc + Scenario: uko-proc vocabulary has no properties for uko_l2 + Given the uko-proc vocabulary for uko_l2 + Then the vocabulary should have 0 properties for uko_l2 + + @uko_proc + Scenario: uko-proc ProceduralFunction has correct parent URI for uko_l2 + Given the uko-proc vocabulary for uko_l2 + When I look up class "ProceduralFunction" for uko_l2 + Then the class should have parent "https://cleveragents.ai/ontology/uko/code#Callable" for uko_l2 + + @uko_proc + Scenario: uko-proc GlobalVariable has correct parent URI for uko_l2 + Given the uko-proc vocabulary for uko_l2 + When I look up class "GlobalVariable" for uko_l2 + Then the class should have parent "https://cleveragents.ai/ontology/uko#Atom" for uko_l2 + + @uko_proc + Scenario: uko-proc HeaderFile has correct parent URI for uko_l2 + Given the uko-proc vocabulary for uko_l2 + When I look up class "HeaderFile" for uko_l2 + Then the class should have parent "https://cleveragents.ai/ontology/uko#Container" for uko_l2 + + @uko_proc + Scenario: uko-proc StructDefinition has correct parent URI for uko_l2 + Given the uko-proc vocabulary for uko_l2 + When I look up class "StructDefinition" for uko_l2 + Then the class should have parent "https://cleveragents.ai/ontology/uko/code#TypeDefinition" for uko_l2 + + @uko_proc + Scenario: uko-proc Macro has correct parent URI for uko_l2 + Given the uko-proc vocabulary for uko_l2 + When I look up class "Macro" for uko_l2 + Then the class should have parent "https://cleveragents.ai/ontology/uko#Atom" for uko_l2 + + # --------------------------------------------------------------------------- + # ParadigmVocabulary — Lookup helpers + # --------------------------------------------------------------------------- + + @vocab_lookup + Scenario: Look up class by label for uko_l2 + Given the uko-oo vocabulary for uko_l2 + When I look up class "Class" for uko_l2 + Then the looked up class should not be None for uko_l2 + And the looked up class uri should be "https://cleveragents.ai/ontology/uko/oo#Class" for uko_l2 + + @vocab_lookup + Scenario: Look up missing class returns None for uko_l2 + Given the uko-oo vocabulary for uko_l2 + When I look up class "NonExistent" for uko_l2 + Then the looked up class should be None for uko_l2 + + @vocab_lookup + Scenario: Look up property by label for uko_l2 + Given the uko-oo vocabulary for uko_l2 + When I look up property "inheritsFrom" for uko_l2 + Then the looked up property should not be None for uko_l2 + + @vocab_lookup + Scenario: Look up missing property returns None for uko_l2 + Given the uko-oo vocabulary for uko_l2 + When I look up property "nonExistent" for uko_l2 + Then the looked up property should be None for uko_l2 + + @vocab_lookup + Scenario: class_uris returns all class URIs for uko_l2 + Given the uko-oo vocabulary for uko_l2 + Then class_uris should have 4 entries for uko_l2 + + @vocab_lookup + Scenario: property_uris returns all property URIs for uko_l2 + Given the uko-oo vocabulary for uko_l2 + Then property_uris should have 2 entries for uko_l2 + + # --------------------------------------------------------------------------- + # VocabularyRegistry + # --------------------------------------------------------------------------- + + @vocab_registry + Scenario: Register and look up vocabularies for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + Then the registry should have 3 vocabularies for uko_l2 + And the registry should contain prefix "uko-oo:" for uko_l2 + And the registry should contain prefix "uko-func:" for uko_l2 + And the registry should contain prefix "uko-proc:" for uko_l2 + + @vocab_registry + Scenario: Look up vocabulary by prefix for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + When I look up vocabulary by prefix "uko-oo:" for uko_l2 + Then the looked up vocabulary should not be None for uko_l2 + And the looked up vocabulary prefix should be "uko-oo:" for uko_l2 + + @vocab_registry + Scenario: Look up vocabulary by IRI for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + When I look up vocabulary by IRI "https://cleveragents.ai/ontology/uko/func#" for uko_l2 + Then the looked up vocabulary should not be None for uko_l2 + And the looked up vocabulary prefix should be "uko-func:" for uko_l2 + + @vocab_registry + Scenario: Look up missing prefix returns None for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + When I look up vocabulary by prefix "uko-unknown:" for uko_l2 + Then the looked up vocabulary should be None for uko_l2 + + @vocab_registry @validation + Scenario: Reject duplicate vocabulary registration for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + When I try to register a duplicate vocabulary for uko_l2 + Then a duplicate registration error should be raised for uko_l2 + + @vocab_registry + Scenario: list_prefixes returns sorted prefixes for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + Then list_prefixes should return "uko-func:,uko-oo:,uko-proc:" for uko_l2 + + @vocab_registry + Scenario: list_all returns all vocabularies for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + Then list_all should return 3 vocabularies for uko_l2 + + @vocab_registry + Scenario: Registry contains check for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + Then "uko-oo:" should be in the registry for uko_l2 + And "uko-missing:" should not be in the registry for uko_l2 + + @vocab_registry + Scenario: Registry contains check supports IRI for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + Then "https://cleveragents.ai/ontology/uko/oo#" should be in the registry for uko_l2 + + @vocab_registry + Scenario: Unregister vocabulary from registry for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + When I unregister prefix "uko-oo:" from the registry for uko_l2 + Then the registry should have 2 vocabularies for uko_l2 + And "uko-oo:" should not be in the registry for uko_l2 + + @vocab_registry + Scenario: Unregister missing prefix returns False for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + When I unregister prefix "uko-missing:" from the registry for uko_l2 + Then the unregister result should be False for uko_l2 + + # --------------------------------------------------------------------------- + # DetailLevelMap — Layer 1 base map (uko-code:) + # --------------------------------------------------------------------------- + + @detail_level_map + Scenario: uko-code base map has correct levels for uko_l2 + Given the uko-code detail level map for uko_l2 + Then the map should have domain "uko-code:" for uko_l2 + And the map should have max_depth 9 for uko_l2 + And the map should resolve "MODULE_LISTING" to 0 for uko_l2 + And the map should resolve "FULL_SOURCE" to 9 for uko_l2 + And the map should resolve "SIGNATURES" to 4 for uko_l2 + + @detail_level_map + Scenario: uko-code base map has 10 named levels for uko_l2 + Given the uko-code detail level map for uko_l2 + Then the map should have 10 levels for uko_l2 + + # --------------------------------------------------------------------------- + # DetailLevelMap — uko-oo: with insertions + # --------------------------------------------------------------------------- + + @detail_level_map @uko_oo + Scenario: uko-oo map inserts CLASS_HIERARCHY at depth 3 for uko_l2 + Given the uko-oo detail level map for uko_l2 + Then the map should have domain "uko-oo:" for uko_l2 + And the map should resolve "CLASS_HIERARCHY" to 3 for uko_l2 + + @detail_level_map @uko_oo + Scenario: uko-oo map inserts VISIBILITY_ANNOTATED at depth 7 for uko_l2 + Given the uko-oo detail level map for uko_l2 + Then the map should resolve "VISIBILITY_ANNOTATED" to 7 for uko_l2 + + @detail_level_map @uko_oo + Scenario: uko-oo map shifts subsequent depths for uko_l2 + Given the uko-oo detail level map for uko_l2 + Then the map should resolve "MEMBER_SUMMARY" to 4 for uko_l2 + And the map should resolve "SIGNATURES" to 5 for uko_l2 + And the map should resolve "SIGNATURES_WITH_DOCS" to 6 for uko_l2 + And the map should resolve "STRUCTURAL_OUTLINE" to 8 for uko_l2 + And the map should resolve "FULL_SOURCE" to 11 for uko_l2 + + @detail_level_map @uko_oo + Scenario: uko-oo map has 12 total levels for uko_l2 + Given the uko-oo detail level map for uko_l2 + Then the map should have 12 levels for uko_l2 + And the map should have max_depth 11 for uko_l2 + + @detail_level_map @uko_oo + Scenario: uko-oo map resolves integer depths for uko_l2 + Given the uko-oo detail level map for uko_l2 + Then the map should resolve integer 0 to 0 for uko_l2 + And the map should resolve integer 5 to 5 for uko_l2 + And the map should resolve integer 100 to 11 for uko_l2 + + @detail_level_map @uko_oo + Scenario: uko-oo map inherits from uko-code parent for uko_l2 + Given the uko-oo detail level map for uko_l2 + Then the map parent domain should be "uko-code:" for uko_l2 + + # --------------------------------------------------------------------------- + # DetailLevelMap — uko-func: + # --------------------------------------------------------------------------- + + @detail_level_map @uko_func + Scenario: uko-func map has same levels as uko-code for uko_l2 + Given the uko-func detail level map for uko_l2 + Then the map should have domain "uko-func:" for uko_l2 + And the map should have 10 levels for uko_l2 + And the map should have max_depth 9 for uko_l2 + And the map should resolve "SIGNATURES" to 4 for uko_l2 + + # --------------------------------------------------------------------------- + # DetailLevelMap — uko-proc: + # --------------------------------------------------------------------------- + + @detail_level_map @uko_proc + Scenario: uko-proc map has same levels as uko-code for uko_l2 + Given the uko-proc detail level map for uko_l2 + Then the map should have domain "uko-proc:" for uko_l2 + And the map should have 10 levels for uko_l2 + And the map should have max_depth 9 for uko_l2 + And the map should resolve "SIGNATURES_WITH_DOCS" to 5 for uko_l2 + + # --------------------------------------------------------------------------- + # DetailLevelMapBuilder — Construction and edge cases + # --------------------------------------------------------------------------- + + @detail_level_map_builder + Scenario: Build a child map with single insertion for uko_l2 + Given a detail level map builder with parent "uko-code:" and domain "test:" for uko_l2 + When I insert "TEST_LEVEL" after "MEMBER_LISTING" for uko_l2 + And I build the map for uko_l2 + Then the built map should have domain "test:" for uko_l2 + And the built map should resolve "TEST_LEVEL" to 3 for uko_l2 + And the built map should resolve "MEMBER_SUMMARY" to 4 for uko_l2 + And the built map should have 11 levels for uko_l2 + + @detail_level_map_builder @validation + Scenario: Reject insertion after non-existent level for uko_l2 + Given a detail level map builder with parent "uko-code:" and domain "test:" for uko_l2 + When I try to insert after non-existent level "BOGUS" for uko_l2 + Then a value error should be raised for uko_l2 + + @detail_level_map_builder @validation + Scenario: Reject builder with empty domain for uko_l2 + When I try to create a builder with empty domain for uko_l2 + Then a value error should be raised for uko_l2 + + @detail_level_map_builder @validation + Scenario: Reject insertion with empty new_level name for uko_l2 + Given a detail level map builder with parent "uko-code:" and domain "test:" for uko_l2 + When I try to insert empty level name after "MEMBER_LISTING" for uko_l2 + Then a value error should be raised for uko_l2 + + @detail_level_map_builder @validation + Scenario: Reject insertion with empty after_level name for uko_l2 + Given a detail level map builder with parent "uko-code:" and domain "test:" for uko_l2 + When I try to insert "TEST" after empty level name for uko_l2 + Then a value error should be raised for uko_l2 + + @detail_level_map_builder + Scenario: Build with multiple insertions for uko_l2 + Given a detail level map builder with parent "uko-code:" and domain "test:" for uko_l2 + When I insert "FIRST" after "MODULE_LISTING" for uko_l2 + And I insert "SECOND" after "FIRST" for uko_l2 + And I build the map for uko_l2 + Then the built map should resolve "FIRST" to 1 for uko_l2 + And the built map should resolve "SECOND" to 2 for uko_l2 + And the built map should resolve "MODULE_GRAPH" to 3 for uko_l2 + + @detail_level_map_builder + Scenario: Builder exposes parent and domain properties for uko_l2 + Given a detail level map builder with parent "uko-code:" and domain "test:" for uko_l2 + Then the builder parent domain should be "uko-code:" for uko_l2 + And the builder domain should be "test:" for uko_l2 + + # --------------------------------------------------------------------------- + # build_effective_map function + # --------------------------------------------------------------------------- + + @effective_map + Scenario: Build effective map with no insertions for uko_l2 + Given the uko-code detail level map for uko_l2 + When I build an effective map with no insertions for uko_l2 + Then the effective map should have 10 entries for uko_l2 + And the effective map entry 0 should be "MODULE_LISTING" at depth 0 for uko_l2 + + @effective_map + Scenario: Build effective map with insertions for uko_l2 + Given the uko-code detail level map for uko_l2 + When I build an effective map with insertion "CLASS_HIERARCHY" after "MEMBER_LISTING" for uko_l2 + Then the effective map should have 11 entries for uko_l2 + And the effective map entry 3 should be "CLASS_HIERARCHY" at depth 3 for uko_l2 + And the effective map entry 4 should be "MEMBER_SUMMARY" at depth 4 for uko_l2 + + @effective_map @validation + Scenario: build_effective_map rejects invalid after_level for uko_l2 + Given the uko-code detail level map for uko_l2 + When I try to build an effective map with insertion after "BOGUS" for uko_l2 + Then a value error should be raised for uko_l2 + + # --------------------------------------------------------------------------- + # DetailLevelMap unknown level resolution + # --------------------------------------------------------------------------- + + @detail_level_map @error + Scenario: Reject unknown named level for uko_l2 + Given the uko-code detail level map for uko_l2 + When I try to resolve unknown level "NONEXISTENT" for uko_l2 + Then a value error should be raised for uko_l2 + + # --------------------------------------------------------------------------- + # Turtle validation — TTL file loads successfully + # --------------------------------------------------------------------------- + + @turtle @validation + Scenario: TTL file loads and validates for uko_l2 + When I load the UKO TTL file for uko_l2 + Then the ontology should load without errors for uko_l2 + And the ontology should contain uko-func prefix for uko_l2 + And the ontology should contain uko-proc prefix for uko_l2 + + @turtle @validation + Scenario: TTL file contains uko-func classes for uko_l2 + When I load the UKO TTL file for uko_l2 + Then the ontology should contain node "PureFunction" for uko_l2 + And the ontology should contain node "TypeClass" for uko_l2 + And the ontology should contain node "Monad" for uko_l2 + + @turtle @validation + Scenario: TTL file contains uko-proc classes for uko_l2 + When I load the UKO TTL file for uko_l2 + Then the ontology should contain node "ProceduralFunction" for uko_l2 + And the ontology should contain node "GlobalVariable" for uko_l2 + And the ontology should contain node "HeaderFile" for uko_l2 + And the ontology should contain node "StructDefinition" for uko_l2 + And the ontology should contain node "Macro" for uko_l2 + + @turtle @validation + Scenario: TTL file contains existing uko-oo classes for uko_l2 + When I load the UKO TTL file for uko_l2 + Then the ontology should contain node "Class" for uko_l2 + And the ontology should contain node "Interface" for uko_l2 + And the ontology should contain node "Method" for uko_l2 + And the ontology should contain node "Attribute" for uko_l2 + + @turtle @validation + Scenario: TTL file has layer 2 nodes for uko_l2 + When I load the UKO TTL file for uko_l2 + Then the ontology should have at least 12 layer 2 nodes for uko_l2 + + # --------------------------------------------------------------------------- + # ParadigmVocabulary — frozen immutability + # --------------------------------------------------------------------------- + + @vocab_frozen + Scenario: ParadigmVocabulary is immutable for uko_l2 + Given the uko-oo vocabulary for uko_l2 + When I try to mutate the vocabulary prefix for uko_l2 + Then a frozen mutation error should be raised for uko_l2 + + # --------------------------------------------------------------------------- + # Additional coverage: validation edge cases + # --------------------------------------------------------------------------- + + @vocab_class @validation + Scenario: Reject ParadigmVocabulary with empty prefix for uko_l2 + When I create a paradigm vocabulary with empty prefix for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_class @validation + Scenario: Reject ParadigmVocabulary with whitespace prefix for uko_l2 + When I create a paradigm vocabulary with whitespace prefix for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_class @validation + Scenario: Reject ParadigmVocabulary with prefix missing colon for uko_l2 + When I create a paradigm vocabulary with prefix missing colon for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_property @validation + Scenario: Reject VocabularyProperty with whitespace-only URI for uko_l2 + When I create a vocabulary property with whitespace uri for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_registry + Scenario: Registry __contains__ returns False for non-string for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + Then the registry should return False for non-string contains for uko_l2 + + @vocab_registry @validation + Scenario: Reject duplicate IRI registration for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + When I try to register a vocabulary with duplicate IRI for uko_l2 + Then a duplicate registration error should be raised for uko_l2 + + @vocab_registry + Scenario: Look up missing IRI returns None for uko_l2 + Given a vocabulary registry with all paradigm vocabularies for uko_l2 + When I look up vocabulary by IRI "https://nonexistent.example.org/" for uko_l2 + Then the looked up vocabulary should be None for uko_l2 + + @detail_level_map + Scenario: DetailLevelMap register adds new level for uko_l2 + Given a fresh detail level map for uko_l2 + When I register level "CUSTOM" with value 5 for uko_l2 + Then the fresh map should resolve "CUSTOM" to 5 for uko_l2 + + @detail_level_map @validation + Scenario: DetailLevelMap register rejects negative depth for uko_l2 + Given a fresh detail level map for uko_l2 + When I try to register level "BAD" with value -1 for uko_l2 + Then a value error should be raised for uko_l2 + + @detail_level_map + Scenario: DetailLevelMap levels are immutable MappingProxy for uko_l2 + Given the uko-code detail level map for uko_l2 + When I try to mutate the levels dict for uko_l2 + Then a type error should be raised for uko_l2 + + @detail_level_map_builder @validation + Scenario: Reject insertion of duplicate level name for uko_l2 + Given a detail level map builder with parent "uko-code:" and domain "test:" for uko_l2 + When I try to insert duplicate level "MODULE_LISTING" after "MEMBER_LISTING" for uko_l2 + Then a value error should be raised for uko_l2 + + @effective_map @validation + Scenario: build_effective_map rejects duplicate level name for uko_l2 + Given the uko-code detail level map for uko_l2 + When I try to build an effective map with duplicate level for uko_l2 + Then a value error should be raised for uko_l2 + + @detail_level_map + Scenario: effective_levels returns immutable mapping for uko_l2 + Given the uko-code detail level map for uko_l2 + When I try to mutate effective_levels for uko_l2 + Then a type error should be raised for uko_l2 + + @detail_level_map @uko_func + Scenario: uko-func map resolves levels via parent inheritance for uko_l2 + Given the uko-func detail level map for uko_l2 + Then the map should resolve "MODULE_LISTING" to 0 for uko_l2 + And the map should resolve "FULL_SOURCE" to 9 for uko_l2 + + # --------------------------------------------------------------------------- + # Review findings: M8 — empty label/domain/range rejection + # --------------------------------------------------------------------------- + + @vocab_property @validation + Scenario: Reject VocabularyProperty with empty label for uko_l2 + When I create a vocabulary property with empty label for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_property @validation + Scenario: Reject VocabularyProperty with empty domain for uko_l2 + When I create a vocabulary property with empty domain for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_property @validation + Scenario: Reject VocabularyProperty with empty range for uko_l2 + When I create a vocabulary property with empty range for uko_l2 + Then a validation error should be raised for uko_l2 + + # --------------------------------------------------------------------------- + # Review findings: M9 — invalid URI scheme rejection + # --------------------------------------------------------------------------- + + @vocab_class @validation + Scenario: Reject VocabularyClass with non-HTTP URI for uko_l2 + When I create a vocabulary class with non-http uri for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_property @validation + Scenario: Reject VocabularyProperty with non-HTTP URI for uko_l2 + When I create a vocabulary property with non-http uri for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_class @validation + Scenario: Reject VocabularyClass with non-HTTP parent URI for uko_l2 + When I create a vocabulary class with non-http parent uri for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_property @validation + Scenario: Reject VocabularyProperty with non-HTTP domain URI for uko_l2 + When I create a vocabulary property with non-http domain uri for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_property @validation + Scenario: Reject VocabularyProperty with non-HTTP range URI for uko_l2 + When I create a vocabulary property with non-http range uri for uko_l2 + Then a validation error should be raised for uko_l2 + + @vocab_property @validation + Scenario: Reject VocabularyProperty with non-HTTP sub_property_of for uko_l2 + When I create a vocabulary property with non-http sub_property_of for uko_l2 + Then a validation error should be raised for uko_l2 + + # --------------------------------------------------------------------------- + # Review findings: M10 — insert_after at tail of level list + # --------------------------------------------------------------------------- + + @detail_level_map_builder + Scenario: Insert after last level FULL_SOURCE for uko_l2 + Given a detail level map builder with parent "uko-code:" and domain "test:" for uko_l2 + When I insert "BEYOND_SOURCE" after "FULL_SOURCE" for uko_l2 + And I build the map for uko_l2 + Then the built map should resolve "BEYOND_SOURCE" to 10 for uko_l2 + And the built map should resolve "FULL_SOURCE" to 9 for uko_l2 + And the built map should have 11 levels for uko_l2 + + # --------------------------------------------------------------------------- + # Review findings: H3 — MappingProxyType serialization + # --------------------------------------------------------------------------- + + @detail_level_map + Scenario: DetailLevelMap model_dump_json succeeds with MappingProxy for uko_l2 + Given the uko-code detail level map for uko_l2 + When I serialize the detail level map to JSON for uko_l2 + Then the serialized JSON should contain "MODULE_LISTING" for uko_l2 + + @detail_level_map + Scenario: DetailLevelMap model_dump succeeds with MappingProxy for uko_l2 + Given the uko-code detail level map for uko_l2 + When I dump the detail level map to dict for uko_l2 + Then the dumped dict levels should contain "MODULE_LISTING" for uko_l2 + + # --------------------------------------------------------------------------- + # Review findings: M5 — cycle guard in resolve() + # --------------------------------------------------------------------------- + + @detail_level_map @error + Scenario: Detect cycle in parent chain during resolve for uko_l2 + Given a detail level map with circular parent for uko_l2 + When I try to resolve level "ANYTHING" on the circular map for uko_l2 + Then a cycle error should be raised for uko_l2 + + # --------------------------------------------------------------------------- + # Review findings: H2 — levels field re-frozen on assignment + # --------------------------------------------------------------------------- + + @detail_level_map + Scenario: Assigning dict to levels re-freezes as MappingProxy for uko_l2 + Given a fresh detail level map for uko_l2 + When I assign a dict to the levels field for uko_l2 + Then the levels should still be an immutable MappingProxy for uko_l2 + + # --------------------------------------------------------------------------- + # Review findings: F7 — cycle guard in effective_levels() + # --------------------------------------------------------------------------- + + @detail_level_map @error + Scenario: Detect cycle in parent chain during effective_levels for uko_l2 + Given a detail level map with circular parent for uko_l2 + When I try to get effective levels on the circular map for uko_l2 + Then a cycle error should be raised for uko_l2 + + # --------------------------------------------------------------------------- + # Review findings: F13 — deepcopy support + # --------------------------------------------------------------------------- + + @detail_level_map + Scenario: Deep copy of DetailLevelMap preserves data for uko_l2 + Given the uko-oo detail level map for uko_l2 + When I deep copy the detail level map for uko_l2 + Then the copied map should have domain "uko-oo:" for uko_l2 + And the copied map should resolve "CLASS_HIERARCHY" to 3 for uko_l2 + And the copied map should not be the same object for uko_l2 diff --git a/features/steps/_uko_l2_test_helpers.py b/features/steps/_uko_l2_test_helpers.py new file mode 100644 index 000000000..ca38b6fd1 --- /dev/null +++ b/features/steps/_uko_l2_test_helpers.py @@ -0,0 +1,56 @@ +"""Shared helpers for UKO Layer 2 BDD step files. + +Provides a ``capture_error`` context manager that reduces the +repeated try/except boilerplate for "negative-test" steps. + +Used by: +- uko_l2_vocabulary_steps.py +- uko_l2_detail_level_steps.py +- uko_l2_coverage_ttl_steps.py +- uko_l2_vocab_registry_steps.py +""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from behave.runner import Context + + +@contextmanager +def capture_error( + ctx: Context, + *exc_types: type[BaseException], +) -> Generator[None]: + """Run the body and capture any matching exception on ``ctx.error``. + + On success ``ctx.error`` is set to ``None``; on failure it is set + to the caught exception instance. + + Usage:: + + with capture_error(ctx, ValidationError): + VocabularyClass(uri="", label="Test") + + Args: + ctx: Behave context (must support attribute assignment). + *exc_types: One or more exception types to catch. + + Yields: + Control to the step body. + + Note: + This intentionally suppresses the caught exception so the + subsequent ``@then`` step can inspect ``ctx.error``. This is + a **test-infrastructure exemption** from CONTRIBUTING.md's + "do not suppress errors" rule -- suppression is the entire + purpose of negative-test capture in BDD step definitions. + """ + try: + yield + ctx.error = None # type: ignore[attr-defined] + except exc_types as exc: + ctx.error = exc # type: ignore[attr-defined] diff --git a/features/steps/uko_l2_coverage_ttl_steps.py b/features/steps/uko_l2_coverage_ttl_steps.py new file mode 100644 index 000000000..dafa3e4f0 --- /dev/null +++ b/features/steps/uko_l2_coverage_ttl_steps.py @@ -0,0 +1,148 @@ +"""Behave steps for UKO Layer 2 TTL validation and additional coverage. + +Covers Turtle file loading, ParadigmVocabulary frozen-model checks, +and miscellaneous validation edge-cases. + +All step definitions use the ``for uko_l2`` suffix to avoid +AmbiguousStep collisions with other feature files. +""" + +from __future__ import annotations + +from pathlib import Path + +from _uko_l2_test_helpers import capture_error +from behave import then, when +from behave.runner import Context +from pydantic import ValidationError + +from cleveragents.acms.uko.vocabularies import ( + ParadigmVocabulary, + VocabularyProperty, + get_oo_vocabulary, +) +from cleveragents.application.services.uko_loader import UKOLoader + +_TTL_PATH = Path(__file__).resolve().parents[2] / "docs" / "ontology" / "uko.ttl" + + +# --------------------------------------------------------------------------- +# Turtle validation +# --------------------------------------------------------------------------- + + +@when("I load the UKO TTL file for uko_l2") +def step_load_ttl(ctx: Context) -> None: + loader = UKOLoader() + ctx.ontology = loader.load(_TTL_PATH) + + +@then("the ontology should load without errors for uko_l2") +def step_check_ontology_loaded(ctx: Context) -> None: + assert ctx.ontology is not None + assert len(ctx.ontology.nodes) > 0 + + +@then("the ontology should contain uko-func prefix for uko_l2") +def step_check_func_prefix(ctx: Context) -> None: + prefixes = {p.prefix for p in ctx.ontology.prefixes} + assert "uko-func" in prefixes, f"uko-func not in {prefixes}" + + +@then("the ontology should contain uko-proc prefix for uko_l2") +def step_check_proc_prefix(ctx: Context) -> None: + prefixes = {p.prefix for p in ctx.ontology.prefixes} + assert "uko-proc" in prefixes, f"uko-proc not in {prefixes}" + + +@then('the ontology should contain node "{label}" for uko_l2') +def step_check_node_exists(ctx: Context, label: str) -> None: + labels = {n.label for n in ctx.ontology.nodes} + assert label in labels, f"Node '{label}' not in {labels}" + + +@then("the ontology should have at least {count:d} layer 2 nodes for uko_l2") +def step_check_layer2_count(ctx: Context, count: int) -> None: + layer2 = [n for n in ctx.ontology.nodes if n.layer == 2] + assert len(layer2) >= count, f"Expected >= {count} layer 2 nodes, got {len(layer2)}" + + +# --------------------------------------------------------------------------- +# ParadigmVocabulary frozen +# --------------------------------------------------------------------------- + + +@when("I try to mutate the vocabulary prefix for uko_l2") +def step_mutate_vocab_prefix(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + ctx.vocabulary.prefix = "changed:" # type: ignore[misc] # deliberate mutation to test frozen model + + +# --------------------------------------------------------------------------- +# Additional coverage steps +# --------------------------------------------------------------------------- + + +@when("I create a paradigm vocabulary with empty prefix for uko_l2") +def step_create_vocab_empty_prefix(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + ParadigmVocabulary( + prefix="", + iri="http://test", + layer=2, + parent_domain="uko-code:", + max_depth=0, + ) + + +@when("I create a paradigm vocabulary with whitespace prefix for uko_l2") +def step_create_vocab_whitespace_prefix(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + ParadigmVocabulary( + prefix=" ", + iri="http://test", + layer=2, + parent_domain="uko-code:", + max_depth=0, + ) + + +@when("I create a vocabulary property with whitespace uri for uko_l2") +def step_create_vocab_prop_whitespace_uri(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyProperty( + uri=" ", + label="test", + domain_uri="https://example.com/d", + range_uri="https://example.com/r", + ) + + +@then("the registry should return False for non-string contains for uko_l2") +def step_check_non_string_contains(ctx: Context) -> None: + assert 42 not in ctx.registry + assert None not in ctx.registry + + +@when("I try to register a vocabulary with duplicate IRI for uko_l2") +def step_register_duplicate_iri(ctx: Context) -> None: + oo = get_oo_vocabulary() + dup = ParadigmVocabulary( + prefix="uko-dup:", + iri=oo.iri, + layer=2, + parent_domain="uko-code:", + ) + with capture_error(ctx, ValueError): + ctx.registry.register(dup) + + +@when("I create a paradigm vocabulary with prefix missing colon for uko_l2") +def step_create_paradigm_vocab_no_colon(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + ParadigmVocabulary( + prefix="uko-test", + iri="https://example.org/test#", + layer=2, + parent_domain="uko-code:", + ) diff --git a/features/steps/uko_l2_detail_level_steps.py b/features/steps/uko_l2_detail_level_steps.py new file mode 100644 index 000000000..c67fb93d6 --- /dev/null +++ b/features/steps/uko_l2_detail_level_steps.py @@ -0,0 +1,386 @@ +"""Behave steps for UKO Layer 2 detail-level map tests. + +Covers DetailLevelMap base maps, DetailLevelMapBuilder, +``build_effective_map``, and unknown-level resolution. + +All step definitions use the ``for uko_l2`` suffix to avoid +AmbiguousStep collisions with other feature files. +""" + +from __future__ import annotations + +import copy +from types import MappingProxyType + +from _uko_l2_test_helpers import capture_error +from behave import given, then, when +from behave.runner import Context + +from cleveragents.acms.uko.detail_level_maps import ( + CODE_DETAIL_LEVEL_MAP, + FUNC_DETAIL_LEVEL_MAP, + OO_DETAIL_LEVEL_MAP, + PROC_DETAIL_LEVEL_MAP, + DetailLevelMapBuilder, + build_effective_map, +) +from cleveragents.domain.models.acms.crp import ( + DetailLevelCycleError, + DetailLevelMap, +) + +# --------------------------------------------------------------------------- +# DetailLevelMap -- base maps +# --------------------------------------------------------------------------- + + +@given("the uko-code detail level map for uko_l2") +def step_get_code_map(ctx: Context) -> None: + ctx.detail_map = CODE_DETAIL_LEVEL_MAP + + +@given("the uko-oo detail level map for uko_l2") +def step_get_oo_map(ctx: Context) -> None: + ctx.detail_map = OO_DETAIL_LEVEL_MAP + + +@given("the uko-func detail level map for uko_l2") +def step_get_func_map(ctx: Context) -> None: + ctx.detail_map = FUNC_DETAIL_LEVEL_MAP + + +@given("the uko-proc detail level map for uko_l2") +def step_get_proc_map(ctx: Context) -> None: + ctx.detail_map = PROC_DETAIL_LEVEL_MAP + + +@then('the map should have domain "{expected}" for uko_l2') +def step_check_map_domain(ctx: Context, expected: str) -> None: + assert ctx.detail_map.domain == expected + + +@then("the map should have max_depth {expected:d} for uko_l2") +def step_check_map_max_depth(ctx: Context, expected: int) -> None: + assert ctx.detail_map.max_depth == expected + + +@then('the map should resolve "{name}" to {expected:d} for uko_l2') +def step_check_map_resolve_name(ctx: Context, name: str, expected: int) -> None: + assert ctx.detail_map.resolve(name) == expected + + +@then("the map should have {count:d} levels for uko_l2") +def step_check_map_level_count(ctx: Context, count: int) -> None: + assert len(ctx.detail_map.effective_levels()) == count + + +@then("the map should resolve integer {value:d} to {expected:d} for uko_l2") +def step_check_map_resolve_int(ctx: Context, value: int, expected: int) -> None: + assert ctx.detail_map.resolve(value) == expected + + +@then('the map parent domain should be "{expected}" for uko_l2') +def step_check_map_parent_domain(ctx: Context, expected: str) -> None: + assert ctx.detail_map.parent is not None + assert ctx.detail_map.parent.domain == expected + + +# --------------------------------------------------------------------------- +# DetailLevelMapBuilder +# --------------------------------------------------------------------------- + + +@given( + 'a detail level map builder with parent "{parent}" and domain "{domain}" for uko_l2' +) +def step_create_builder(ctx: Context, parent: str, domain: str) -> None: + parent_map = CODE_DETAIL_LEVEL_MAP if parent == "uko-code:" else None + assert parent_map is not None, f"Unknown parent: {parent}" + ctx.builder = DetailLevelMapBuilder(parent_map, domain) + + +@when('I insert "{name}" after "{after}" for uko_l2') +def step_builder_insert(ctx: Context, name: str, after: str) -> None: + ctx.builder.insert_after(after, name) + + +@when("I build the map for uko_l2") +def step_builder_build(ctx: Context) -> None: + ctx.built_map = ctx.builder.build() + + +@then('the built map should have domain "{expected}" for uko_l2') +def step_check_built_domain(ctx: Context, expected: str) -> None: + assert ctx.built_map.domain == expected + + +@then('the built map should resolve "{name}" to {expected:d} for uko_l2') +def step_check_built_resolve(ctx: Context, name: str, expected: int) -> None: + assert ctx.built_map.resolve(name) == expected + + +@then("the built map should have {count:d} levels for uko_l2") +def step_check_built_levels(ctx: Context, count: int) -> None: + assert len(ctx.built_map.effective_levels()) == count + + +@when('I try to insert after non-existent level "{name}" for uko_l2') +def step_insert_nonexistent(ctx: Context, name: str) -> None: + with capture_error(ctx, ValueError): + ctx.builder.insert_after(name, "TEST") + + +@then("a value error should be raised for uko_l2") +def step_check_value_error(ctx: Context) -> None: + assert ctx.error is not None + assert isinstance(ctx.error, ValueError) + + +@when("I try to create a builder with empty domain for uko_l2") +def step_create_builder_empty_domain(ctx: Context) -> None: + with capture_error(ctx, ValueError): + DetailLevelMapBuilder(CODE_DETAIL_LEVEL_MAP, "") + + +@when('I try to insert empty level name after "{after}" for uko_l2') +def step_insert_empty_name(ctx: Context, after: str) -> None: + with capture_error(ctx, ValueError): + ctx.builder.insert_after(after, "") + + +@when('I try to insert "{name}" after empty level name for uko_l2') +def step_insert_after_empty(ctx: Context, name: str) -> None: + with capture_error(ctx, ValueError): + ctx.builder.insert_after("", name) + + +@then('the builder parent domain should be "{expected}" for uko_l2') +def step_check_builder_parent(ctx: Context, expected: str) -> None: + assert ctx.builder.parent.domain == expected + + +@then('the builder domain should be "{expected}" for uko_l2') +def step_check_builder_domain(ctx: Context, expected: str) -> None: + assert ctx.builder.domain == expected + + +# --------------------------------------------------------------------------- +# build_effective_map function +# --------------------------------------------------------------------------- + + +@when("I build an effective map with no insertions for uko_l2") +def step_build_effective_no_insert(ctx: Context) -> None: + ctx.effective = build_effective_map(ctx.detail_map, []) + + +@when('I build an effective map with insertion "{name}" after "{after}" for uko_l2') +def step_build_effective_insert(ctx: Context, name: str, after: str) -> None: + ctx.effective = build_effective_map(ctx.detail_map, [(after, name)]) + + +@when('I try to build an effective map with insertion after "{after}" for uko_l2') +def step_build_effective_invalid(ctx: Context, after: str) -> None: + with capture_error(ctx, ValueError): + build_effective_map(ctx.detail_map, [(after, "TEST")]) + + +@then("the effective map should have {count:d} entries for uko_l2") +def step_check_effective_count(ctx: Context, count: int) -> None: + assert len(ctx.effective) == count + + +@then( + 'the effective map entry {index:d} should be "{name}" at depth {depth:d} for uko_l2' +) +def step_check_effective_entry(ctx: Context, index: int, name: str, depth: int) -> None: + entry_name, entry_depth = ctx.effective[index] + assert entry_name == name, f"Expected '{name}' but got '{entry_name}'" + assert entry_depth == depth, f"Expected depth {depth} but got {entry_depth}" + + +# --------------------------------------------------------------------------- +# Unknown level resolution +# --------------------------------------------------------------------------- + + +@when('I try to resolve unknown level "{name}" for uko_l2') +def step_resolve_unknown(ctx: Context, name: str) -> None: + with capture_error(ctx, ValueError): + ctx.detail_map.resolve(name) + + +# --------------------------------------------------------------------------- +# Fresh DetailLevelMap (register / mutate) +# --------------------------------------------------------------------------- + + +@given("a fresh detail level map for uko_l2") +def step_create_fresh_map(ctx: Context) -> None: + ctx.fresh_map = DetailLevelMap( + domain="test:", parent=None, levels={"BASE": 0}, max_depth=10 + ) + + +@when('I register level "{name}" with value {value:d} for uko_l2') +def step_register_level(ctx: Context, name: str, value: int) -> None: + ctx.fresh_map.register(name, value) + + +@then('the fresh map should resolve "{name}" to {expected:d} for uko_l2') +def step_check_fresh_resolve(ctx: Context, name: str, expected: int) -> None: + assert ctx.fresh_map.resolve(name) == expected + + +@when('I try to register level "{name}" with value {value:d} for uko_l2') +def step_try_register_negative(ctx: Context, name: str, value: int) -> None: + with capture_error(ctx, ValueError): + ctx.fresh_map.register(name, value) + + +@when("I try to mutate the levels dict for uko_l2") +def step_mutate_levels(ctx: Context) -> None: + with capture_error(ctx, TypeError): + ctx.detail_map.levels["HACK"] = 99 + + +@then("a type error should be raised for uko_l2") +def step_check_type_error(ctx: Context) -> None: + assert ctx.error is not None + assert isinstance(ctx.error, TypeError) + + +@when('I try to insert duplicate level "{name}" after "{after}" for uko_l2') +def step_try_insert_duplicate(ctx: Context, name: str, after: str) -> None: + with capture_error(ctx, ValueError): + ctx.builder.insert_after(after, name) + + +@when("I try to build an effective map with duplicate level for uko_l2") +def step_build_effective_duplicate(ctx: Context) -> None: + with capture_error(ctx, ValueError): + build_effective_map(ctx.detail_map, [("MEMBER_LISTING", "MODULE_LISTING")]) + + +@when("I try to mutate effective_levels for uko_l2") +def step_mutate_effective_levels(ctx: Context) -> None: + with capture_error(ctx, TypeError): + effective = ctx.detail_map.effective_levels() + effective["HACK"] = 99 # type: ignore[index] # deliberate mutation test + + +# --------------------------------------------------------------------------- +# Review findings: H3 — MappingProxyType serialization +# --------------------------------------------------------------------------- + + +@when("I serialize the detail level map to JSON for uko_l2") +def step_serialize_map_json(ctx: Context) -> None: + ctx.serialized_json = ctx.detail_map.model_dump_json() + + +@then('the serialized JSON should contain "{expected}" for uko_l2') +def step_check_serialized_json(ctx: Context, expected: str) -> None: + assert expected in ctx.serialized_json + + +@when("I dump the detail level map to dict for uko_l2") +def step_dump_map_dict(ctx: Context) -> None: + ctx.dumped_dict = ctx.detail_map.model_dump() + + +@then('the dumped dict levels should contain "{expected}" for uko_l2') +def step_check_dumped_dict(ctx: Context, expected: str) -> None: + assert expected in ctx.dumped_dict["levels"] + + +# --------------------------------------------------------------------------- +# Review findings: M5 — cycle guard in resolve() +# --------------------------------------------------------------------------- + + +@given("a detail level map with circular parent for uko_l2") +def step_create_circular_map(ctx: Context) -> None: + # Build two maps that point to each other + map_a = DetailLevelMap(domain="cycle-a:", parent=None, levels={"A": 0}, max_depth=5) + map_b = DetailLevelMap( + domain="cycle-b:", parent=map_a, levels={"B": 1}, max_depth=5 + ) + # Introduce cycle: map_a.parent -> map_b + object.__setattr__(map_a, "parent", map_b) + ctx.circular_map = map_a + + +@when('I try to resolve level "{name}" on the circular map for uko_l2') +def step_resolve_circular(ctx: Context, name: str) -> None: + with capture_error(ctx, DetailLevelCycleError): + ctx.circular_map.resolve(name) + + +@then("a cycle error should be raised for uko_l2") +def step_check_cycle_error(ctx: Context) -> None: + assert ctx.error is not None + assert isinstance(ctx.error, DetailLevelCycleError) + + +# --------------------------------------------------------------------------- +# Review findings: H2 — levels re-frozen on assignment +# --------------------------------------------------------------------------- + + +@when("I assign a dict to the levels field for uko_l2") +def step_assign_dict_levels(ctx: Context) -> None: + ctx.fresh_map.levels = {"NEW_LEVEL": 3} # type: ignore[assignment] # deliberate test + ctx.levels_type = type(ctx.fresh_map.levels) + ctx.is_mapping_proxy = isinstance(ctx.fresh_map.levels, MappingProxyType) + + +@then("the levels should still be an immutable MappingProxy for uko_l2") +def step_check_levels_refrozen(ctx: Context) -> None: + assert ctx.is_mapping_proxy, f"Expected MappingProxyType but got {ctx.levels_type}" + # Also verify it's actually immutable. + # Note: capture_error() is not used here because the pattern is + # "assert that an exception IS raised" (inline assertion), not + # "capture the error for a later @then step". + try: + ctx.fresh_map.levels["HACK"] = 99 # type: ignore[index] # deliberate mutation test + msg = "Expected TypeError on mutation" + raise AssertionError(msg) + except TypeError: + pass + + +# --------------------------------------------------------------------------- +# Review findings: F7 — cycle guard in effective_levels() +# --------------------------------------------------------------------------- + + +@when("I try to get effective levels on the circular map for uko_l2") +def step_effective_levels_circular(ctx: Context) -> None: + with capture_error(ctx, DetailLevelCycleError): + ctx.circular_map.effective_levels() + + +# --------------------------------------------------------------------------- +# Review findings: F13 — deepcopy support +# --------------------------------------------------------------------------- + + +@when("I deep copy the detail level map for uko_l2") +def step_deep_copy_map(ctx: Context) -> None: + ctx.copied_map = copy.deepcopy(ctx.detail_map) + + +@then('the copied map should have domain "{expected}" for uko_l2') +def step_check_copied_domain(ctx: Context, expected: str) -> None: + assert ctx.copied_map.domain == expected + + +@then('the copied map should resolve "{name}" to {expected:d} for uko_l2') +def step_check_copied_resolve(ctx: Context, name: str, expected: int) -> None: + assert ctx.copied_map.resolve(name) == expected + + +@then("the copied map should not be the same object for uko_l2") +def step_check_copied_identity(ctx: Context) -> None: + assert ctx.copied_map is not ctx.detail_map diff --git a/features/steps/uko_l2_vocab_registry_steps.py b/features/steps/uko_l2_vocab_registry_steps.py new file mode 100644 index 000000000..5848ea166 --- /dev/null +++ b/features/steps/uko_l2_vocab_registry_steps.py @@ -0,0 +1,123 @@ +"""Behave steps for UKO Layer 2 vocabulary registry tests. + +All step definitions use the ``for uko_l2`` suffix to avoid +AmbiguousStep collisions with other feature files. +""" + +from __future__ import annotations + +from _uko_l2_test_helpers import capture_error +from behave import given, then, when +from behave.runner import Context + +from cleveragents.acms.uko.vocabularies import ( + get_func_vocabulary, + get_oo_vocabulary, + get_proc_vocabulary, +) +from cleveragents.acms.uko.vocabulary_registry import VocabularyRegistry + +# --------------------------------------------------------------------------- +# VocabularyRegistry -- construction and lookup +# --------------------------------------------------------------------------- + + +@given("a vocabulary registry with all paradigm vocabularies for uko_l2") +def step_create_registry(ctx: Context) -> None: + ctx.registry = VocabularyRegistry( + vocabularies=( + get_oo_vocabulary(), + get_func_vocabulary(), + get_proc_vocabulary(), + ) + ) + + +@then("the registry should have {count:d} vocabularies for uko_l2") +def step_check_registry_count(ctx: Context, count: int) -> None: + assert len(ctx.registry) == count + + +@then('the registry should contain prefix "{prefix}" for uko_l2') +def step_check_registry_prefix(ctx: Context, prefix: str) -> None: + assert ctx.registry.get_by_prefix(prefix) is not None + + +@when('I look up vocabulary by prefix "{prefix}" for uko_l2') +def step_lookup_vocab_prefix(ctx: Context, prefix: str) -> None: + ctx.looked_up_vocab = ctx.registry.get_by_prefix(prefix) + + +@when('I look up vocabulary by IRI "{iri}" for uko_l2') +def step_lookup_vocab_iri(ctx: Context, iri: str) -> None: + ctx.looked_up_vocab = ctx.registry.get_by_iri(iri) + + +@then("the looked up vocabulary should not be None for uko_l2") +def step_vocab_not_none(ctx: Context) -> None: + assert ctx.looked_up_vocab is not None + + +@then("the looked up vocabulary should be None for uko_l2") +def step_vocab_is_none(ctx: Context) -> None: + assert ctx.looked_up_vocab is None + + +@then('the looked up vocabulary prefix should be "{expected}" for uko_l2') +def step_check_looked_up_vocab_prefix(ctx: Context, expected: str) -> None: + assert ctx.looked_up_vocab is not None + assert ctx.looked_up_vocab.prefix == expected + + +# --------------------------------------------------------------------------- +# Registration / deregistration +# --------------------------------------------------------------------------- + + +@when("I try to register a duplicate vocabulary for uko_l2") +def step_register_duplicate(ctx: Context) -> None: + with capture_error(ctx, ValueError): + ctx.registry.register(get_oo_vocabulary()) + + +@then("a duplicate registration error should be raised for uko_l2") +def step_check_duplicate_error(ctx: Context) -> None: + assert ctx.error is not None + assert isinstance(ctx.error, ValueError) + assert "already registered" in str(ctx.error) + + +@when('I unregister prefix "{prefix}" from the registry for uko_l2') +def step_unregister_prefix(ctx: Context, prefix: str) -> None: + ctx.unregister_result = ctx.registry.unregister(prefix) + + +@then("the unregister result should be False for uko_l2") +def step_check_unregister_false(ctx: Context) -> None: + assert ctx.unregister_result is False + + +# --------------------------------------------------------------------------- +# Enumeration and contains +# --------------------------------------------------------------------------- + + +@then('list_prefixes should return "{expected}" for uko_l2') +def step_check_list_prefixes(ctx: Context, expected: str) -> None: + prefixes = ctx.registry.list_prefixes() + assert ",".join(prefixes) == expected + + +@then("list_all should return {count:d} vocabularies for uko_l2") +def step_check_list_all(ctx: Context, count: int) -> None: + assert len(ctx.registry.list_all()) == count + + +@then('"{prefix}" should be in the registry for uko_l2') +def step_check_contains(ctx: Context, prefix: str) -> None: + assert prefix in ctx.registry + + +@then('"{prefix}" should not be in the registry for uko_l2') +def step_check_not_contains(ctx: Context, prefix: str) -> None: + assert prefix not in ctx.registry diff --git a/features/steps/uko_l2_vocabulary_steps.py b/features/steps/uko_l2_vocabulary_steps.py new file mode 100644 index 000000000..17deab0e1 --- /dev/null +++ b/features/steps/uko_l2_vocabulary_steps.py @@ -0,0 +1,427 @@ +"""Behave steps for UKO Layer 2 vocabulary model and registry tests. + +All step definitions use the ``for uko_l2`` suffix to avoid +AmbiguousStep collisions with other feature files. +""" + +from __future__ import annotations + +from _uko_l2_test_helpers import capture_error +from behave import given, then, when +from behave.runner import Context +from pydantic import ValidationError + +from cleveragents.acms.uko.vocabularies import ( + VocabularyClass, + VocabularyProperty, + get_func_vocabulary, + get_oo_vocabulary, + get_proc_vocabulary, +) + +# --------------------------------------------------------------------------- +# VocabularyClass -- construction +# --------------------------------------------------------------------------- + + +@given('a simple vocabulary class with uri "{uri}" and label "{label}" for uko_l2') +def step_create_vocab_class(ctx: Context, uri: str, label: str) -> None: + ctx.vocab_class = VocabularyClass(uri=uri, label=label) + + +@given( + 'a vocabulary class with uri "{uri}" and label "{label}" ' + 'and parents "{parents}" for uko_l2' +) +def step_create_vocab_class_parents( + ctx: Context, uri: str, label: str, parents: str +) -> None: + parent_uris = tuple(p.strip() for p in parents.split(",")) + ctx.vocab_class = VocabularyClass(uri=uri, label=label, parent_uris=parent_uris) + + +@then('the vocabulary class uri should be "{expected}" for uko_l2') +def step_check_vocab_class_uri(ctx: Context, expected: str) -> None: + assert ctx.vocab_class.uri == expected + + +@then('the vocabulary class label should be "{expected}" for uko_l2') +def step_check_vocab_class_label(ctx: Context, expected: str) -> None: + assert ctx.vocab_class.label == expected + + +@then("the vocabulary class comment should be empty for uko_l2") +def step_check_vocab_class_comment_empty(ctx: Context) -> None: + assert ctx.vocab_class.comment == "" + + +@then("the vocabulary class parent_uris should be empty for uko_l2") +def step_check_vocab_class_parents_empty(ctx: Context) -> None: + assert ctx.vocab_class.parent_uris == () + + +@then("the vocabulary class should have {count:d} parent URIs for uko_l2") +def step_check_vocab_class_parent_count(ctx: Context, count: int) -> None: + assert len(ctx.vocab_class.parent_uris) == count + + +@then('the vocabulary class parent {index:d} should be "{expected}" for uko_l2') +def step_check_vocab_class_parent_idx(ctx: Context, index: int, expected: str) -> None: + assert ctx.vocab_class.parent_uris[index] == expected + + +# --------------------------------------------------------------------------- +# VocabularyClass -- validation +# --------------------------------------------------------------------------- + + +@when("I create a vocabulary class with empty uri for uko_l2") +def step_create_vocab_class_empty_uri(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyClass(uri="", label="Test") + + +@when("I create a vocabulary class with whitespace uri for uko_l2") +def step_create_vocab_class_whitespace_uri(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyClass(uri=" ", label="Test") + + +@when("I create a vocabulary class with empty label for uko_l2") +def step_create_vocab_class_empty_label(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyClass(uri="http://example.org/test", label="") + + +@then("a validation error should be raised for uko_l2") +def step_check_validation_error(ctx: Context) -> None: + assert ctx.error is not None, "Expected a validation error but none was raised" + assert isinstance(ctx.error, (ValidationError, ValueError)) + + +# --------------------------------------------------------------------------- +# VocabularyClass -- frozen +# --------------------------------------------------------------------------- + + +@when("I try to mutate the vocabulary class uri for uko_l2") +def step_mutate_vocab_class(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + ctx.vocab_class.uri = "http://changed" # type: ignore[misc] # deliberate mutation to test frozen model + + +@then("a frozen mutation error should be raised for uko_l2") +def step_check_frozen_error(ctx: Context) -> None: + assert ctx.error is not None, "Expected a frozen mutation error" + + +# --------------------------------------------------------------------------- +# VocabularyProperty -- construction +# --------------------------------------------------------------------------- + + +@given( + 'a basic vocabulary property with uri "{uri}" and label "{label}" ' + 'and domain "{domain}" and range "{range_uri}" for uko_l2' +) +def step_create_vocab_property( + ctx: Context, uri: str, label: str, domain: str, range_uri: str +) -> None: + ctx.vocab_property = VocabularyProperty( + uri=uri, label=label, domain_uri=domain, range_uri=range_uri + ) + + +@given( + 'a vocabulary property with uri "{uri}" and label "{label}" ' + 'and domain "{domain}" and range "{range_uri}" ' + 'and sub_property_of "{sub_prop}" for uko_l2' +) +def step_create_vocab_property_sub( + ctx: Context, + uri: str, + label: str, + domain: str, + range_uri: str, + sub_prop: str, +) -> None: + ctx.vocab_property = VocabularyProperty( + uri=uri, + label=label, + domain_uri=domain, + range_uri=range_uri, + sub_property_of=sub_prop, + ) + + +@then('the vocabulary property uri should be "{expected}" for uko_l2') +def step_check_vocab_prop_uri(ctx: Context, expected: str) -> None: + assert ctx.vocab_property.uri == expected + + +@then('the vocabulary property label should be "{expected}" for uko_l2') +def step_check_vocab_prop_label(ctx: Context, expected: str) -> None: + assert ctx.vocab_property.label == expected + + +@then("the vocabulary property sub_property_of should be empty for uko_l2") +def step_check_vocab_prop_sub_empty(ctx: Context) -> None: + assert ctx.vocab_property.sub_property_of is None + + +@then('the vocabulary property sub_property_of should be "{expected}" for uko_l2') +def step_check_vocab_prop_sub(ctx: Context, expected: str) -> None: + assert ctx.vocab_property.sub_property_of == expected + + +@when("I create a vocabulary property with empty uri for uko_l2") +def step_create_vocab_prop_empty_uri(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyProperty( + uri="", + label="test", + domain_uri="https://example.com/d", + range_uri="https://example.com/r", + ) + + +@when("I try to mutate the vocabulary property uri for uko_l2") +def step_mutate_vocab_property(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + ctx.vocab_property.uri = "http://changed" # type: ignore[misc] # deliberate mutation to test frozen model + + +# --------------------------------------------------------------------------- +# Paradigm vocabularies -- OO, Func, Proc +# --------------------------------------------------------------------------- + + +@given("the uko-oo vocabulary for uko_l2") +def step_get_oo_vocab(ctx: Context) -> None: + ctx.vocabulary = get_oo_vocabulary() + + +@given("the uko-func vocabulary for uko_l2") +def step_get_func_vocab(ctx: Context) -> None: + ctx.vocabulary = get_func_vocabulary() + + +@given("the uko-proc vocabulary for uko_l2") +def step_get_proc_vocab(ctx: Context) -> None: + ctx.vocabulary = get_proc_vocabulary() + + +@then('the vocabulary should have prefix "{expected}" for uko_l2') +def step_check_vocab_prefix(ctx: Context, expected: str) -> None: + assert ctx.vocabulary.prefix == expected + + +@then('the vocabulary should have IRI "{expected}" for uko_l2') +def step_check_vocab_iri(ctx: Context, expected: str) -> None: + assert ctx.vocabulary.iri == expected + + +@then("the vocabulary should have layer {layer:d} for uko_l2") +def step_check_vocab_layer(ctx: Context, layer: int) -> None: + assert ctx.vocabulary.layer == layer + + +@then("the vocabulary should have {count:d} classes for uko_l2") +def step_check_vocab_class_count(ctx: Context, count: int) -> None: + assert len(ctx.vocabulary.classes) == count + + +@then("the vocabulary should have {count:d} properties for uko_l2") +def step_check_vocab_prop_count(ctx: Context, count: int) -> None: + assert len(ctx.vocabulary.properties) == count + + +@then('the vocabulary should contain class "{name}" for uko_l2') +def step_check_vocab_has_class(ctx: Context, name: str) -> None: + found = ctx.vocabulary.get_class(name) + assert found is not None, f"Class '{name}' not found" + + +@then('the vocabulary should contain property "{name}" for uko_l2') +def step_check_vocab_has_property(ctx: Context, name: str) -> None: + found = ctx.vocabulary.get_property(name) + assert found is not None, f"Property '{name}' not found" + + +@when('I look up class "{name}" for uko_l2') +def step_lookup_class(ctx: Context, name: str) -> None: + ctx.looked_up_class = ctx.vocabulary.get_class(name) + + +@when('I look up property "{name}" for uko_l2') +def step_lookup_property(ctx: Context, name: str) -> None: + ctx.looked_up_property = ctx.vocabulary.get_property(name) + + +@then('the class should have parent "{expected}" for uko_l2') +def step_check_class_parent(ctx: Context, expected: str) -> None: + assert ctx.looked_up_class is not None, "Class was not looked up" + assert expected in ctx.looked_up_class.parent_uris, ( + f"Expected parent '{expected}' not found in {ctx.looked_up_class.parent_uris}" + ) + + +@then('the property domain should be "{expected}" for uko_l2') +def step_check_prop_domain(ctx: Context, expected: str) -> None: + assert ctx.looked_up_property is not None + assert ctx.looked_up_property.domain_uri == expected + + +@then('the property range should be "{expected}" for uko_l2') +def step_check_prop_range(ctx: Context, expected: str) -> None: + assert ctx.looked_up_property is not None + assert ctx.looked_up_property.range_uri == expected + + +@then('the property sub_property_of should be "{expected}" for uko_l2') +def step_check_prop_sub_property(ctx: Context, expected: str) -> None: + assert ctx.looked_up_property is not None + assert ctx.looked_up_property.sub_property_of == expected + + +@then("the looked up class should not be None for uko_l2") +def step_class_not_none(ctx: Context) -> None: + assert ctx.looked_up_class is not None + + +@then("the looked up class should be None for uko_l2") +def step_class_is_none(ctx: Context) -> None: + assert ctx.looked_up_class is None + + +@then('the looked up class uri should be "{expected}" for uko_l2') +def step_check_looked_up_class_uri(ctx: Context, expected: str) -> None: + assert ctx.looked_up_class is not None + assert ctx.looked_up_class.uri == expected + + +@then("the looked up property should not be None for uko_l2") +def step_prop_not_none(ctx: Context) -> None: + assert ctx.looked_up_property is not None + + +@then("the looked up property should be None for uko_l2") +def step_prop_is_none(ctx: Context) -> None: + assert ctx.looked_up_property is None + + +@then("class_uris should have {count:d} entries for uko_l2") +def step_check_class_uris_count(ctx: Context, count: int) -> None: + assert len(ctx.vocabulary.class_uris()) == count + + +@then("property_uris should have {count:d} entries for uko_l2") +def step_check_property_uris_count(ctx: Context, count: int) -> None: + assert len(ctx.vocabulary.property_uris()) == count + + +# --------------------------------------------------------------------------- +# Review findings: M8 — empty label/domain/range rejection +# --------------------------------------------------------------------------- + + +@when("I create a vocabulary property with empty label for uko_l2") +def step_create_vocab_prop_empty_label(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyProperty( + uri="https://example.com/prop", + label="", + domain_uri="https://example.com/d", + range_uri="https://example.com/r", + ) + + +@when("I create a vocabulary property with empty domain for uko_l2") +def step_create_vocab_prop_empty_domain(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyProperty( + uri="https://example.com/prop", + label="test", + domain_uri="", + range_uri="https://example.com/r", + ) + + +@when("I create a vocabulary property with empty range for uko_l2") +def step_create_vocab_prop_empty_range(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyProperty( + uri="https://example.com/prop", + label="test", + domain_uri="https://example.com/d", + range_uri="", + ) + + +# --------------------------------------------------------------------------- +# Review findings: M9 — invalid URI scheme rejection +# --------------------------------------------------------------------------- + + +@when("I create a vocabulary class with non-http uri for uko_l2") +def step_create_vocab_class_non_http(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyClass( + uri="ftp://example.com/BadClass", + label="BadClass", + ) + + +@when("I create a vocabulary property with non-http uri for uko_l2") +def step_create_vocab_prop_non_http(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyProperty( + uri="urn:example:bad", + label="bad", + domain_uri="https://example.com/d", + range_uri="https://example.com/r", + ) + + +@when("I create a vocabulary class with non-http parent uri for uko_l2") +def step_create_vocab_class_non_http_parent(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyClass( + uri="https://example.com/Good", + label="Good", + parent_uris=("ftp://example.com/BadParent",), + ) + + +@when("I create a vocabulary property with non-http domain uri for uko_l2") +def step_create_vocab_prop_non_http_domain(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyProperty( + uri="https://example.com/prop", + label="prop", + domain_uri="ftp://example.com/BadDomain", + range_uri="https://example.com/r", + ) + + +@when("I create a vocabulary property with non-http range uri for uko_l2") +def step_create_vocab_prop_non_http_range(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyProperty( + uri="https://example.com/prop", + label="prop", + domain_uri="https://example.com/d", + range_uri="file:///etc/passwd", + ) + + +@when("I create a vocabulary property with non-http sub_property_of for uko_l2") +def step_create_vocab_prop_non_http_subprop(ctx: Context) -> None: + with capture_error(ctx, ValidationError): + VocabularyProperty( + uri="https://example.com/prop", + label="prop", + domain_uri="https://example.com/d", + range_uri="https://example.com/r", + sub_property_of="javascript:alert(1)", + ) diff --git a/features/uko_ontology.feature b/features/uko_ontology.feature index 1f6920211..65c2ec53b 100644 --- a/features/uko_ontology.feature +++ b/features/uko_ontology.feature @@ -189,7 +189,7 @@ Feature: UKO Ontology Loader | layer | count | | 0 | 18 | | 1 | 67 | - | 2 | 6 | + | 2 | 14 | # --------------------------------------------------------------------------- # rdfs:domain and rdfs:range URI resolution diff --git a/robot/helper_uko_layer2_paradigm.py b/robot/helper_uko_layer2_paradigm.py new file mode 100644 index 000000000..d25df3a6f --- /dev/null +++ b/robot/helper_uko_layer2_paradigm.py @@ -0,0 +1,239 @@ +"""Robot Framework helper for UKO Layer 2 paradigm vocabulary tests. + +Subcommands: + vocab-oo -- verify uko-oo: vocabulary, print ``vocab-oo-ok`` + vocab-func -- verify uko-func: vocabulary, print ``vocab-func-ok`` + vocab-proc -- verify uko-proc: vocabulary, print ``vocab-proc-ok`` + detail-map-chain -- verify L2->L1->L0 chain, print ``detail-map-chain-ok`` + ttl-validate -- validate TTL with new prefixes, print ``ttl-validate-ok`` +""" + +from __future__ import annotations + +import sys +import traceback +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.acms.uko.detail_level_maps import ( # noqa: E402 + CODE_DETAIL_LEVEL_MAP, + FUNC_DETAIL_LEVEL_MAP, + OO_DETAIL_LEVEL_MAP, + PROC_DETAIL_LEVEL_MAP, +) +from cleveragents.acms.uko.vocabularies import ( # noqa: E402 + get_func_vocabulary, + get_oo_vocabulary, + get_proc_vocabulary, +) +from cleveragents.acms.uko.vocabulary_registry import VocabularyRegistry # noqa: E402 +from cleveragents.application.services.uko_loader import UKOLoader # noqa: E402 + +_TTL_PATH = Path(__file__).resolve().parents[1] / "docs" / "ontology" / "uko.ttl" + + +def _cmd_vocab_oo() -> int: + """Verify uko-oo: vocabulary has expected classes and properties.""" + try: + vocab = get_oo_vocabulary() + if len(vocab.classes) != 4: + print(f"vocab-oo-fail: expected 4 classes, got {len(vocab.classes)}") + return 1 + if len(vocab.properties) != 2: + print(f"vocab-oo-fail: expected 2 properties, got {len(vocab.properties)}") + return 1 + expected_classes = {"Class", "Interface", "Method", "Attribute"} + actual_classes = {c.label for c in vocab.classes} + if actual_classes != expected_classes: + print(f"vocab-oo-fail: classes mismatch: {actual_classes}") + return 1 + print( + f"vocab-oo-ok: {len(vocab.classes)} classes, " + f"{len(vocab.properties)} properties" + ) + return 0 + except Exception as exc: # robot helper must not crash + traceback.print_exc() + print(f"vocab-oo-fail: {exc}") + return 1 + + +def _cmd_vocab_func() -> int: + """Verify uko-func: vocabulary has expected classes.""" + try: + vocab = get_func_vocabulary() + if len(vocab.classes) != 3: + print(f"vocab-func-fail: expected 3 classes, got {len(vocab.classes)}") + return 1 + expected = {"PureFunction", "TypeClass", "Monad"} + actual = {c.label for c in vocab.classes} + if actual != expected: + print(f"vocab-func-fail: classes mismatch: {actual}") + return 1 + print(f"vocab-func-ok: {len(vocab.classes)} classes") + return 0 + except Exception as exc: # robot helper must not crash + traceback.print_exc() + print(f"vocab-func-fail: {exc}") + return 1 + + +def _cmd_vocab_proc() -> int: + """Verify uko-proc: vocabulary has expected classes.""" + try: + vocab = get_proc_vocabulary() + if len(vocab.classes) != 5: + print(f"vocab-proc-fail: expected 5 classes, got {len(vocab.classes)}") + return 1 + expected = { + "ProceduralFunction", + "GlobalVariable", + "HeaderFile", + "StructDefinition", + "Macro", + } + actual = {c.label for c in vocab.classes} + if actual != expected: + print(f"vocab-proc-fail: classes mismatch: {actual}") + return 1 + print(f"vocab-proc-ok: {len(vocab.classes)} classes") + return 0 + except Exception as exc: # robot helper must not crash + traceback.print_exc() + print(f"vocab-proc-fail: {exc}") + return 1 + + +def _cmd_detail_map_chain() -> int: + """Verify DetailLevelMap inheritance chain: L2 -> L1 -> L0.""" + try: + # Verify OO map extends CODE map + oo_map = OO_DETAIL_LEVEL_MAP + if oo_map.parent is None: + print("detail-map-chain-fail: OO map has no parent") + return 1 + if oo_map.parent.domain != "uko-code:": + print(f"detail-map-chain-fail: OO parent is {oo_map.parent.domain}") + return 1 + + # Verify OO map has CLASS_HIERARCHY at depth 3 + depth = oo_map.resolve("CLASS_HIERARCHY") + if depth != 3: + print(f"detail-map-chain-fail: CLASS_HIERARCHY at {depth}, expected 3") + return 1 + + # Verify OO map has VISIBILITY_ANNOTATED at depth 7 + depth = oo_map.resolve("VISIBILITY_ANNOTATED") + if depth != 7: + print(f"detail-map-chain-fail: VISIBILITY_ANNOTATED at {depth}, expected 7") + return 1 + + # Verify FULL_SOURCE shifted to 11 + depth = oo_map.resolve("FULL_SOURCE") + if depth != 11: + print(f"detail-map-chain-fail: FULL_SOURCE at {depth}, expected 11") + return 1 + + # Verify func and proc maps have correct parent + func_map = FUNC_DETAIL_LEVEL_MAP + proc_map = PROC_DETAIL_LEVEL_MAP + if func_map.parent is None or func_map.parent.domain != "uko-code:": + print("detail-map-chain-fail: FUNC map parent incorrect") + return 1 + if proc_map.parent is None or proc_map.parent.domain != "uko-code:": + print("detail-map-chain-fail: PROC map parent incorrect") + return 1 + + # Verify CODE map has no parent + code_map = CODE_DETAIL_LEVEL_MAP + if code_map.parent is not None: + print("detail-map-chain-fail: CODE map should have no parent") + return 1 + + print( + f"detail-map-chain-ok: OO={len(oo_map.levels)} levels, " + f"FUNC={len(func_map.levels)}, PROC={len(proc_map.levels)}, " + f"CODE={len(code_map.levels)}" + ) + return 0 + except Exception as exc: # robot helper must not crash + traceback.print_exc() + print(f"detail-map-chain-fail: {exc}") + return 1 + + +def _cmd_ttl_validate() -> int: + """Load and validate the updated TTL file with new prefixes.""" + try: + loader = UKOLoader() + ontology = loader.load(_TTL_PATH) + prefixes = {p.prefix for p in ontology.prefixes} + if "uko-func" not in prefixes: + print("ttl-validate-fail: uko-func prefix missing") + return 1 + if "uko-proc" not in prefixes: + print("ttl-validate-fail: uko-proc prefix missing") + return 1 + + # Check layer 2 node count + layer2 = [n for n in ontology.nodes if n.layer == 2] + if len(layer2) < 12: + print(f"ttl-validate-fail: expected >= 12 L2 nodes, got {len(layer2)}") + return 1 + + # Verify registry can be populated + registry = VocabularyRegistry( + vocabularies=( + get_oo_vocabulary(), + get_func_vocabulary(), + get_proc_vocabulary(), + ) + ) + if len(registry) != 3: + print(f"ttl-validate-fail: registry has {len(registry)} vocabs") + return 1 + + print( + f"ttl-validate-ok: {len(ontology.nodes)} nodes, " + f"{len(layer2)} layer-2, {len(registry)} vocabs" + ) + return 0 + except Exception as exc: # robot helper must not crash + traceback.print_exc() + print(f"ttl-validate-fail: {exc}") + return 1 + + +_COMMANDS: dict[str, Callable[[], int]] = { + "vocab-oo": _cmd_vocab_oo, + "vocab-func": _cmd_vocab_func, + "vocab-proc": _cmd_vocab_proc, + "detail-map-chain": _cmd_detail_map_chain, + "ttl-validate": _cmd_ttl_validate, +} + + +def main() -> int: + """Entry point called by Robot Framework ``Run Process``.""" + if len(sys.argv) < 2: + print( + "Usage: helper_uko_layer2_paradigm.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/src/cleveragents/acms/__init__.py b/src/cleveragents/acms/__init__.py new file mode 100644 index 000000000..be5b04e5c --- /dev/null +++ b/src/cleveragents/acms/__init__.py @@ -0,0 +1,31 @@ +"""ACMS (Advanced Context Management System) UKO vocabulary support. + +Provides UKO Layer 2 paradigm vocabulary specializations and the +DetailLevelMap inheritance mechanism for resolving named detail levels +across the ontology hierarchy (Layer 2 -> Layer 1 -> Layer 0). + +Based on ``docs/specification.md`` ~lines 42333-42422. +""" + +from __future__ import annotations + +from cleveragents.acms import uko as _uko +from cleveragents.acms.uko import ( + CODE_DETAIL_LEVEL_MAP, + FUNC_DETAIL_LEVEL_MAP, + OO_DETAIL_LEVEL_MAP, + PROC_DETAIL_LEVEL_MAP, + DetailLevelMapBuilder, + ParadigmVocabulary, + VocabularyClass, + VocabularyProperty, + VocabularyRegistry, + build_effective_map, + get_func_vocabulary, + get_oo_vocabulary, + get_proc_vocabulary, +) + +# Re-export everything published by the ``uko`` sub-package so the two +# ``__all__`` lists stay in sync automatically. +__all__: list[str] = list(_uko.__all__) diff --git a/src/cleveragents/acms/uko/__init__.py b/src/cleveragents/acms/uko/__init__.py new file mode 100644 index 000000000..57538597f --- /dev/null +++ b/src/cleveragents/acms/uko/__init__.py @@ -0,0 +1,56 @@ +"""UKO Layer 2 paradigm vocabulary specializations. + +Provides Object-Oriented (``uko-oo:``), Functional (``uko-func:``), and +Procedural (``uko-proc:``) vocabulary definitions with DetailLevelMap +inheritance that extends the Layer 1 ``uko-code:`` domain. + +Namespace prefixes (per specification.md ~line 41900): + +| Prefix | IRI | Layer | +|---------------|-------------------------------------------------|-------| +| ``uko-oo:`` | ``https://cleveragents.ai/ontology/uko/oo#`` | 2 | +| ``uko-func:`` | ``https://cleveragents.ai/ontology/uko/func#`` | 2 | +| ``uko-proc:`` | ``https://cleveragents.ai/ontology/uko/proc#`` | 2 | + +Based on ``docs/specification.md`` ~lines 42333-42422. +""" + +from __future__ import annotations + +from cleveragents.acms.uko.detail_level_maps import ( + CODE_DETAIL_LEVEL_MAP, + FUNC_DETAIL_LEVEL_MAP, + OO_DETAIL_LEVEL_MAP, + PROC_DETAIL_LEVEL_MAP, + DetailLevelMapBuilder, + build_effective_map, +) +from cleveragents.acms.uko.vocabularies import ( + ParadigmVocabulary, + VocabularyClass, + VocabularyProperty, + get_func_vocabulary, + get_oo_vocabulary, + get_proc_vocabulary, +) +from cleveragents.acms.uko.vocabulary_registry import ( + DuplicateVocabularyError, + VocabularyRegistry, +) + +__all__: list[str] = [ + "CODE_DETAIL_LEVEL_MAP", + "FUNC_DETAIL_LEVEL_MAP", + "OO_DETAIL_LEVEL_MAP", + "PROC_DETAIL_LEVEL_MAP", + "DetailLevelMapBuilder", + "DuplicateVocabularyError", + "ParadigmVocabulary", + "VocabularyClass", + "VocabularyProperty", + "VocabularyRegistry", + "build_effective_map", + "get_func_vocabulary", + "get_oo_vocabulary", + "get_proc_vocabulary", +] diff --git a/src/cleveragents/acms/uko/detail_level_maps.py b/src/cleveragents/acms/uko/detail_level_maps.py new file mode 100644 index 000000000..f0e2cd685 --- /dev/null +++ b/src/cleveragents/acms/uko/detail_level_maps.py @@ -0,0 +1,296 @@ +"""DetailLevelMap inheritance for UKO Layer 2 paradigm vocabularies. + +Implements the DetailLevelMap inheritance chain where Layer 2 (paradigm) +maps extend the Layer 1 (``uko-code:``) base map by inserting new named +levels at specific integer positions and reassigning subsequent depths. + +The inheritance chain is: + Layer 2 (e.g., ``uko-oo:``) -> Layer 1 (``uko-code:``) -> Layer 0 (``uko:``) + +When a paradigm map inserts a level (e.g., ``CLASS_HIERARCHY`` at depth 3), +all subsequent depths in the parent map are shifted upward by one to +maintain consecutive integer numbering. + +Thread Safety +------------- +Module-level map constants (``CODE_DETAIL_LEVEL_MAP``, etc.) are +initialised once at import time under the GIL and are thereafter +read-only. Do **not** call ``register()`` on these constants after +module initialisation. + +Based on ``docs/specification.md`` ~lines 24923-25027, 42380-42469. +""" + +from __future__ import annotations + +from types import MappingProxyType + +import structlog + +from cleveragents.domain.models.acms.crp import DetailLevelMap + +__all__: list[str] = [ + "CODE_DETAIL_LEVEL_MAP", + "FUNC_DETAIL_LEVEL_MAP", + "OO_DETAIL_LEVEL_MAP", + "PROC_DETAIL_LEVEL_MAP", + "DetailLevelMapBuilder", + "build_effective_map", +] + +_log = structlog.get_logger(__name__) + +#: Default max depth for code-domain maps (spec lines 24970-24985). +_CODE_MAX_DEPTH: int = 9 + +#: Parent domain prefix for Layer 2 maps. +_CODE_DOMAIN: str = "uko-code:" + +# --------------------------------------------------------------------------- +# Layer 1 base map: uko-code: (spec lines 24970-24985) +# --------------------------------------------------------------------------- + +_CODE_LEVELS: MappingProxyType[str, int] = MappingProxyType( + { + "MODULE_LISTING": 0, + "MODULE_GRAPH": 1, + "MEMBER_LISTING": 2, + "MEMBER_SUMMARY": 3, + "SIGNATURES": 4, + "SIGNATURES_WITH_DOCS": 5, + "STRUCTURAL_OUTLINE": 6, + "KEY_LOGIC": 7, + "NEAR_COMPLETE": 8, + "FULL_SOURCE": 9, + } +) + +#: Layer 1 base map. Read-only after module initialisation. +CODE_DETAIL_LEVEL_MAP = DetailLevelMap( + domain=_CODE_DOMAIN, + parent=None, + levels=dict(_CODE_LEVELS), + max_depth=_CODE_MAX_DEPTH, +) + + +# --------------------------------------------------------------------------- +# DetailLevelMap builder with insertion and reassignment logic +# --------------------------------------------------------------------------- + + +class DetailLevelMapBuilder: + """Build a child DetailLevelMap that inherits from a parent. + + Supports inserting new named levels at specific positions, which + shifts all subsequent integer depth assignments upward. + + Args: + parent: The parent DetailLevelMap to inherit from. + domain: The UKO namespace for the new map (e.g., ``"uko-oo:"``). + + Raises: + ValueError: If *domain* is empty or whitespace-only. + + Example:: + + builder = DetailLevelMapBuilder(CODE_DETAIL_LEVEL_MAP, "uko-oo:") + builder.insert_after("MEMBER_LISTING", "CLASS_HIERARCHY") + builder.insert_after("SIGNATURES_WITH_DOCS", "VISIBILITY_ANNOTATED") + oo_map = builder.build() + assert oo_map.resolve("CLASS_HIERARCHY") == 3 + """ + + def __init__(self, parent: DetailLevelMap, domain: str) -> None: + if not domain or not domain.strip(): + raise ValueError("domain must be a non-empty string") + self._parent = parent + self._domain = domain.strip() + self._insertions: list[tuple[str, str]] = [] + _log.debug( + "detail_level_map_builder.created", + parent_domain=parent.domain, + child_domain=self._domain, + ) + + @property + def parent(self) -> DetailLevelMap: + """Return the parent map.""" + return self._parent + + @property + def domain(self) -> str: + """Return the domain namespace.""" + return self._domain + + def insert_after(self, after_level: str, new_level: str) -> None: + """Insert a new named level after an existing one. + + The new level will be assigned the next integer after + *after_level*, and all subsequent levels shift upward by one. + + Args: + after_level: Name of the existing level to insert after. + new_level: Name of the new level to insert. + + Raises: + ValueError: If *after_level* is not found in the parent's + effective map, if *new_level* already exists, or if + either argument is empty/whitespace. + """ + if not new_level or not new_level.strip(): + raise ValueError("new_level must be a non-empty string") + if not after_level or not after_level.strip(): + raise ValueError("after_level must be a non-empty string") + after_stripped = after_level.strip() + known = set(self._parent.effective_levels().keys()) + known.update(new_name for _, new_name in self._insertions) + if after_stripped not in known: + raise ValueError( + f"Level '{after_stripped}' not found in parent map " + f"'{self._parent.domain}'" + ) + stripped = new_level.strip() + if stripped in known: + raise ValueError( + f"Level '{stripped}' already exists in map '{self._parent.domain}'" + ) + self._insertions.append((after_stripped, stripped)) + _log.debug( + "detail_level_map_builder.insert_after", + after_level=after_stripped, + new_level=stripped, + domain=self._domain, + ) + + def build(self) -> DetailLevelMap: + """Build the child DetailLevelMap with insertions applied. + + Returns: + A new DetailLevelMap whose levels include all parent levels + plus inserted levels, with integer depths reassigned + consecutively. + + Raises: + ValueError: If a duplicate level name would be introduced + (caught by ``build_effective_map``). + """ + effective = build_effective_map(self._parent, self._insertions) + + # build_effective_map already validates no duplicate names + levels = {name: depth for name, depth in effective} + max_depth = max(levels.values()) if levels else 0 + + result = DetailLevelMap( + domain=self._domain, + parent=self._parent, + levels=levels, + max_depth=max_depth, + ) + + _log.debug( + "detail_level_map_builder.built", + domain=self._domain, + num_levels=len(levels), + max_depth=max_depth, + ) + return result + + +def build_effective_map( + parent: DetailLevelMap, + insertions: list[tuple[str, str]] | tuple[tuple[str, str], ...], +) -> tuple[tuple[str, int], ...]: + """Build the effective (name, depth) pairs for a child map. + + Takes a parent map and a sequence of (after_level, new_level) + insertions, applies them in order, and returns the fully + reassigned depth mappings as consecutive integers starting from 0. + + Args: + parent: Parent DetailLevelMap to extend. + insertions: Sequence of (after_level, new_level) pairs. + + Returns: + Tuple of (name, depth) pairs ordered by depth. + + Raises: + ValueError: If an after_level is not found in the current map, + or if a duplicate level name would be introduced. + """ + # Start with parent's effective levels sorted by depth + parent_levels = parent.effective_levels() + sorted_levels = sorted(parent_levels.items(), key=lambda x: x[1]) + + # Build ordered list of level names with O(1) membership set + ordered_names: list[str] = [name for name, _depth in sorted_levels] + known: set[str] = set(ordered_names) + + # Apply insertions in order + for after_level, new_level in insertions: + if after_level not in known: + raise ValueError( + f"Level '{after_level}' not found in effective map " + f"during insertion of '{new_level}'" + ) + if new_level in known: + raise ValueError(f"Duplicate level name '{new_level}' during insertion") + idx = ordered_names.index(after_level) + ordered_names.insert(idx + 1, new_level) + known.add(new_level) + + # Reassign consecutive integers + return tuple((name, i) for i, name in enumerate(ordered_names)) + + +# --------------------------------------------------------------------------- +# Layer 2 maps: uko-oo:, uko-func:, uko-proc: +# --------------------------------------------------------------------------- + + +def _build_oo_map() -> DetailLevelMap: + """Build the uko-oo: DetailLevelMap. + + Inserts CLASS_HIERARCHY after MEMBER_LISTING (depth 3) and + VISIBILITY_ANNOTATED after SIGNATURES_WITH_DOCS (depth 7). + + Based on specification.md ~lines 24987-25004. + """ + builder = DetailLevelMapBuilder(CODE_DETAIL_LEVEL_MAP, "uko-oo:") + builder.insert_after("MEMBER_LISTING", "CLASS_HIERARCHY") + # Note: issue #575 describes this as "SIGNATURES_WITH_DOCS at depth 5" + # but the spec (lines 24987-25004) defines VISIBILITY_ANNOTATED as + # the OO-specific insertion. SIGNATURES_WITH_DOCS already exists in + # the parent uko-code: map. + builder.insert_after("SIGNATURES_WITH_DOCS", "VISIBILITY_ANNOTATED") + return builder.build() + + +def _build_passthrough_map(domain: str) -> DetailLevelMap: + """Build a passthrough child DetailLevelMap that inherits all parent levels. + + Used for paradigm maps (e.g., ``uko-func:``, ``uko-proc:``) that inherit + all named levels from ``uko-code:`` without additional insertions. Levels + are resolved via ``effective_levels()`` through the parent chain. + + Args: + domain: UKO namespace for the child map (e.g., ``"uko-func:"``). + + Returns: + A new ``DetailLevelMap`` with empty own levels and + ``max_depth`` inherited from the code-domain constant. + """ + return DetailLevelMap( + domain=domain, + parent=CODE_DETAIL_LEVEL_MAP, + levels={}, + max_depth=_CODE_MAX_DEPTH, + ) + + +#: Layer 2 OO map. Read-only after module initialisation. +OO_DETAIL_LEVEL_MAP: DetailLevelMap = _build_oo_map() +#: Layer 2 functional map. Read-only after module initialisation. +FUNC_DETAIL_LEVEL_MAP: DetailLevelMap = _build_passthrough_map("uko-func:") +#: Layer 2 procedural map. Read-only after module initialisation. +PROC_DETAIL_LEVEL_MAP: DetailLevelMap = _build_passthrough_map("uko-proc:") diff --git a/src/cleveragents/acms/uko/vocabularies.py b/src/cleveragents/acms/uko/vocabularies.py new file mode 100644 index 000000000..fb5621963 --- /dev/null +++ b/src/cleveragents/acms/uko/vocabularies.py @@ -0,0 +1,486 @@ +"""UKO Layer 2 paradigm vocabulary definitions. + +Defines the OWL class and property vocabularies for the three Layer 2 +paradigm specializations: Object-Oriented (``uko-oo:``), Functional +(``uko-func:``), and Procedural (``uko-proc:``). + +Each vocabulary declares: +- Classes with their ``rdfs:subClassOf`` parent URIs +- Object properties with domain/range/subPropertyOf +- Namespace prefix and IRI + +Based on ``docs/specification.md`` ~lines 42384-42468. +""" + +from __future__ import annotations + +import functools + +import structlog +from pydantic import BaseModel, ConfigDict, Field, field_validator + +__all__: list[str] = [ + "UKO_ATOM", + "UKO_BOUNDARY", + "UKO_CODE_CALLABLE", + "UKO_CODE_TYPE_DEFINITION", + "UKO_CONTAINER", + "UKO_DEPENDS_ON", + "UKO_FUNC_IRI", + "UKO_FUNC_PREFIX", + "UKO_OO_IRI", + "UKO_OO_PREFIX", + "UKO_PROC_IRI", + "UKO_PROC_PREFIX", + "ParadigmVocabulary", + "VocabularyClass", + "VocabularyProperty", + "get_func_vocabulary", + "get_oo_vocabulary", + "get_proc_vocabulary", +] + +_log = structlog.get_logger(__name__) + +# --------------------------------------------------------------------------- +# Namespace constants (spec line 41900-41902) +# --------------------------------------------------------------------------- + +UKO_OO_PREFIX = "uko-oo:" +UKO_OO_IRI = "https://cleveragents.ai/ontology/uko/oo#" + +UKO_FUNC_PREFIX = "uko-func:" +UKO_FUNC_IRI = "https://cleveragents.ai/ontology/uko/func#" + +UKO_PROC_PREFIX = "uko-proc:" +UKO_PROC_IRI = "https://cleveragents.ai/ontology/uko/proc#" + +# Layer 1 URIs that Layer 2 depends on (will be wired when Layer 1 lands) +UKO_CODE_TYPE_DEFINITION = "https://cleveragents.ai/ontology/uko/code#TypeDefinition" +UKO_CODE_CALLABLE = "https://cleveragents.ai/ontology/uko/code#Callable" + +# Layer 0 URIs +UKO_CONTAINER = "https://cleveragents.ai/ontology/uko#Container" +UKO_ATOM = "https://cleveragents.ai/ontology/uko#Atom" +UKO_BOUNDARY = "https://cleveragents.ai/ontology/uko#Boundary" +UKO_DEPENDS_ON = "https://cleveragents.ai/ontology/uko#dependsOn" + + +# --------------------------------------------------------------------------- +# Shared URI validation helper +# --------------------------------------------------------------------------- + + +def _validate_http_uri(uri: str, *, field_name: str = "URI") -> str: + """Validate that *uri* uses an HTTP or HTTPS scheme. + + This is a **scheme-only** check. These URIs are used as ontology + identifiers and are never fetched at runtime, so full URL parsing + and SSRF mitigation are not required here. + + Args: + uri: The URI string to validate. + field_name: Human-readable field name for error messages. + + Returns: + The validated URI string (unchanged). + + Raises: + ValueError: If *uri* does not start with ``http://`` or ``https://``. + """ + if not uri.startswith(("http://", "https://")): + raise ValueError(f"{field_name} must use http(s) scheme, got: {uri!r}") + return uri + + +# --------------------------------------------------------------------------- +# Vocabulary models +# --------------------------------------------------------------------------- + + +class VocabularyClass(BaseModel, frozen=True): + """An OWL class definition within a paradigm vocabulary. + + Represents a single ``owl:Class`` with its label, comment, and + ``rdfs:subClassOf`` parent URIs. + + Args: + uri: Full IRI of this class. + label: Human-readable label (``rdfs:label``). + comment: Description (``rdfs:comment``). + parent_uris: Tuple of ``rdfs:subClassOf`` parent IRIs. + """ + + uri: str = Field( + ..., + min_length=1, + description="Full IRI of this OWL class", + ) + label: str = Field( + ..., + min_length=1, + description="rdfs:label for this class", + ) + comment: str = Field( + default="", + description="rdfs:comment describing this class", + ) + parent_uris: tuple[str, ...] = Field( + default=(), + description="rdfs:subClassOf parent IRIs", + ) + + @field_validator("uri") + @classmethod + def _validate_uri(cls: type[VocabularyClass], v: str) -> str: + """Validate URI uses an HTTP(S) scheme.""" + return _validate_http_uri(v, field_name="class URI") + + @field_validator("parent_uris") + @classmethod + def _validate_parent_uris( + cls: type[VocabularyClass], + v: tuple[str, ...], + ) -> tuple[str, ...]: + """Validate all parent URIs use an HTTP(S) scheme.""" + for uri in v: + _validate_http_uri(uri, field_name="parent_uris entry") + return v + + model_config = ConfigDict(str_strip_whitespace=True) + + +class VocabularyProperty(BaseModel, frozen=True): + """An OWL property definition within a paradigm vocabulary. + + Represents an ``owl:ObjectProperty`` with domain, range, and + optional ``rdfs:subPropertyOf``. + + Args: + uri: Full IRI of this property. + label: Human-readable label. + domain_uri: The ``rdfs:domain`` class IRI. + range_uri: The ``rdfs:range`` class IRI. + sub_property_of: Optional ``rdfs:subPropertyOf`` parent IRI. + """ + + uri: str = Field( + ..., + min_length=1, + description="Full IRI of this OWL property", + ) + label: str = Field( + ..., + min_length=1, + description="rdfs:label for this property", + ) + domain_uri: str = Field( + ..., + min_length=1, + description="rdfs:domain class IRI", + ) + range_uri: str = Field( + ..., + min_length=1, + description="rdfs:range class IRI", + ) + sub_property_of: str | None = Field( + default=None, + description="rdfs:subPropertyOf parent IRI, or None if not a sub-property", + ) + + @field_validator("uri") + @classmethod + def _validate_uri(cls: type[VocabularyProperty], v: str) -> str: + """Validate URI uses an HTTP(S) scheme.""" + return _validate_http_uri(v, field_name="property URI") + + @field_validator("domain_uri") + @classmethod + def _validate_domain_uri(cls: type[VocabularyProperty], v: str) -> str: + """Validate domain URI uses an HTTP(S) scheme.""" + return _validate_http_uri(v, field_name="domain_uri") + + @field_validator("range_uri") + @classmethod + def _validate_range_uri(cls: type[VocabularyProperty], v: str) -> str: + """Validate range URI uses an HTTP(S) scheme.""" + return _validate_http_uri(v, field_name="range_uri") + + @field_validator("sub_property_of") + @classmethod + def _validate_sub_property_of( + cls: type[VocabularyProperty], + v: str | None, + ) -> str | None: + """Validate sub_property_of URI uses an HTTP(S) scheme if set.""" + if v is not None: + _validate_http_uri(v, field_name="sub_property_of") + return v + + model_config = ConfigDict(str_strip_whitespace=True) + + +class ParadigmVocabulary(BaseModel, frozen=True): + """A complete Layer 2 paradigm vocabulary definition. + + Groups all OWL classes and properties for a single paradigm + (e.g., Object-Oriented, Functional, Procedural) with its + namespace prefix and IRI. + + Args: + prefix: Short prefix (e.g., ``"uko-oo:"``). + iri: Full namespace IRI. + layer: Ontology layer number (always 2 for paradigm vocabs). + parent_domain: Parent Layer 1 domain prefix. + classes: Tuple of OWL class definitions. + properties: Tuple of OWL property definitions. + """ + + prefix: str = Field( + ..., + min_length=1, + description="Short namespace prefix (e.g., 'uko-oo:')", + ) + iri: str = Field( + ..., + min_length=1, + description="Full namespace IRI", + ) + layer: int = Field( + ..., + ge=0, + le=7, + description="Ontology layer number (e.g., 2 for paradigm vocabs)", + ) + parent_domain: str = Field( + ..., + min_length=1, + description="Parent domain prefix (e.g., 'uko-code:' for Layer 2)", + ) + classes: tuple[VocabularyClass, ...] = Field( + default=(), + description="OWL class definitions", + ) + properties: tuple[VocabularyProperty, ...] = Field( + default=(), + description="OWL property definitions", + ) + + @field_validator("prefix") + @classmethod + def _validate_prefix(cls: type[ParadigmVocabulary], v: str) -> str: + """Validate prefix is non-empty and ends with a colon.""" + stripped = v.strip() + if not stripped: + raise ValueError("prefix must be a non-empty string") + if not stripped.endswith(":"): + raise ValueError(f"prefix must end with ':', got: {stripped!r}") + return stripped + + @field_validator("iri") + @classmethod + def _validate_iri(cls: type[ParadigmVocabulary], v: str) -> str: + """Validate IRI uses http or https scheme.""" + return _validate_http_uri(v, field_name="vocabulary IRI") + + def get_class(self, label: str) -> VocabularyClass | None: + """Look up a class by its label. + + Args: + label: The ``rdfs:label`` of the class to find. + + Returns: + The matching VocabularyClass, or None if not found. + """ + for cls in self.classes: + if cls.label == label: + return cls + _log.debug("vocabulary.class_not_found", label=label, prefix=self.prefix) + return None + + def get_property(self, label: str) -> VocabularyProperty | None: + """Look up a property by its label. + + Args: + label: The ``rdfs:label`` of the property to find. + + Returns: + The matching VocabularyProperty, or None if not found. + """ + for prop in self.properties: + if prop.label == label: + return prop + _log.debug("vocabulary.property_not_found", label=label, prefix=self.prefix) + return None + + def class_uris(self) -> tuple[str, ...]: + """Return all class URIs in this vocabulary.""" + return tuple(cls.uri for cls in self.classes) + + def property_uris(self) -> tuple[str, ...]: + """Return all property URIs in this vocabulary.""" + return tuple(prop.uri for prop in self.properties) + + model_config = ConfigDict(str_strip_whitespace=True) + + +# --------------------------------------------------------------------------- +# uko-oo: vocabulary (spec lines 42384-42411) +# --------------------------------------------------------------------------- + + +@functools.lru_cache(maxsize=1) +def get_oo_vocabulary() -> ParadigmVocabulary: + """Return the Object-Oriented paradigm vocabulary. + + Classes: Class, Interface, Method, Attribute. + Properties: inheritsFrom, implements. + + Based on specification.md ~lines 42384-42411. + """ + return ParadigmVocabulary( + prefix=UKO_OO_PREFIX, + iri=UKO_OO_IRI, + layer=2, + parent_domain="uko-code:", + classes=( + VocabularyClass( + uri=f"{UKO_OO_IRI}Class", + label="Class", + comment="An OO class that contains methods and attributes.", + parent_uris=(UKO_CODE_TYPE_DEFINITION, UKO_CONTAINER), + ), + VocabularyClass( + uri=f"{UKO_OO_IRI}Interface", + label="Interface", + comment="An interface or abstract base class.", + parent_uris=(UKO_CODE_TYPE_DEFINITION, UKO_BOUNDARY), + ), + VocabularyClass( + uri=f"{UKO_OO_IRI}Method", + label="Method", + comment="A method defined within a class.", + parent_uris=(UKO_CODE_CALLABLE,), + ), + VocabularyClass( + uri=f"{UKO_OO_IRI}Attribute", + label="Attribute", + comment="A class or instance attribute.", + parent_uris=(UKO_ATOM,), + ), + ), + properties=( + VocabularyProperty( + uri=f"{UKO_OO_IRI}inheritsFrom", + label="inheritsFrom", + domain_uri=f"{UKO_OO_IRI}Class", + range_uri=f"{UKO_OO_IRI}Class", + sub_property_of=UKO_DEPENDS_ON, + ), + VocabularyProperty( + uri=f"{UKO_OO_IRI}implements", + label="implements", + domain_uri=f"{UKO_OO_IRI}Class", + range_uri=f"{UKO_OO_IRI}Interface", + sub_property_of=UKO_DEPENDS_ON, + ), + ), + ) + + +# --------------------------------------------------------------------------- +# uko-func: vocabulary (spec lines 42413-42425) +# --------------------------------------------------------------------------- + + +@functools.lru_cache(maxsize=1) +def get_func_vocabulary() -> ParadigmVocabulary: + """Return the Functional paradigm vocabulary. + + Classes: PureFunction, TypeClass, Monad. + + Based on specification.md ~lines 42413-42425. + """ + return ParadigmVocabulary( + prefix=UKO_FUNC_PREFIX, + iri=UKO_FUNC_IRI, + layer=2, + parent_domain="uko-code:", + classes=( + VocabularyClass( + uri=f"{UKO_FUNC_IRI}PureFunction", + label="PureFunction", + comment="A pure function with no side effects.", + parent_uris=(UKO_CODE_CALLABLE,), + ), + VocabularyClass( + uri=f"{UKO_FUNC_IRI}TypeClass", + label="TypeClass", + comment="A type class defining a set of operations for types.", + parent_uris=(UKO_CODE_TYPE_DEFINITION, UKO_BOUNDARY), + ), + VocabularyClass( + uri=f"{UKO_FUNC_IRI}Monad", + label="Monad", + comment="A monadic type encapsulating computation context.", + parent_uris=(UKO_CODE_TYPE_DEFINITION,), + ), + ), + properties=(), + ) + + +# --------------------------------------------------------------------------- +# uko-proc: vocabulary (spec lines 42460-42468) +# --------------------------------------------------------------------------- + + +@functools.lru_cache(maxsize=1) +def get_proc_vocabulary() -> ParadigmVocabulary: + """Return the Procedural paradigm vocabulary. + + Classes: ProceduralFunction, GlobalVariable, HeaderFile, + StructDefinition, Macro. + + Based on specification.md ~lines 42460-42468. + """ + return ParadigmVocabulary( + prefix=UKO_PROC_PREFIX, + iri=UKO_PROC_IRI, + layer=2, + parent_domain="uko-code:", + classes=( + VocabularyClass( + uri=f"{UKO_PROC_IRI}ProceduralFunction", + label="ProceduralFunction", + comment="A procedural function declaration.", + parent_uris=(UKO_CODE_CALLABLE,), + ), + VocabularyClass( + uri=f"{UKO_PROC_IRI}GlobalVariable", + label="GlobalVariable", + comment="A global variable declaration.", + parent_uris=(UKO_ATOM,), + ), + VocabularyClass( + uri=f"{UKO_PROC_IRI}HeaderFile", + label="HeaderFile", + comment="A header file defining exported declarations.", + parent_uris=(UKO_CONTAINER,), + ), + VocabularyClass( + uri=f"{UKO_PROC_IRI}StructDefinition", + label="StructDefinition", + comment="A C-style struct type definition.", + parent_uris=(UKO_CODE_TYPE_DEFINITION,), + ), + VocabularyClass( + uri=f"{UKO_PROC_IRI}Macro", + label="Macro", + comment="A preprocessor macro definition.", + parent_uris=(UKO_ATOM,), + ), + ), + properties=(), + ) diff --git a/src/cleveragents/acms/uko/vocabulary_registry.py b/src/cleveragents/acms/uko/vocabulary_registry.py new file mode 100644 index 000000000..a2cf4618c --- /dev/null +++ b/src/cleveragents/acms/uko/vocabulary_registry.py @@ -0,0 +1,145 @@ +"""Vocabulary registry for UKO Layer 2 paradigm vocabularies. + +Provides a ``VocabularyRegistry`` that stores and retrieves +``ParadigmVocabulary`` instances by prefix or IRI. + +Thread Safety +------------- +``VocabularyRegistry`` is **not** thread-safe. External +synchronisation is required if instances are shared across threads +after initial population. Typical usage creates and populates a +registry once during module initialisation under the GIL. +""" + +from __future__ import annotations + +from bisect import insort + +import structlog + +from cleveragents.acms.uko.vocabularies import ParadigmVocabulary + +__all__: list[str] = ["DuplicateVocabularyError", "VocabularyRegistry"] + +_log = structlog.get_logger(__name__) + + +class DuplicateVocabularyError(ValueError): + """Raised when registering a vocabulary with a duplicate prefix or IRI.""" + + +class VocabularyRegistry: + """Registry of Layer 2 paradigm vocabularies. + + Provides lookup by prefix or IRI and lists all registered + vocabularies. An internal sorted prefix list is maintained so + that ``list_prefixes`` and ``list_all`` avoid re-sorting on + every call. + + Mutation Safety + ~~~~~~~~~~~~~~~ + ``register()`` and ``unregister()`` mutate internal state. The + registry does **not** make defensive copies of vocabularies on + retrieval; callers receive the original frozen + ``ParadigmVocabulary`` instances. + + Args: + vocabularies: Initial vocabularies to register. + """ + + def __init__( + self, + vocabularies: tuple[ParadigmVocabulary, ...] = (), + ) -> None: + self._by_prefix: dict[str, ParadigmVocabulary] = {} + self._by_iri: dict[str, ParadigmVocabulary] = {} + self._sorted_prefixes: list[str] = [] + for vocab in vocabularies: + self.register(vocab) + + def register(self, vocabulary: ParadigmVocabulary) -> None: + """Register a paradigm vocabulary. + + Args: + vocabulary: The vocabulary to register. + + Raises: + DuplicateVocabularyError: If a vocabulary with the same + prefix or IRI is already registered. + """ + if vocabulary.prefix in self._by_prefix: + raise DuplicateVocabularyError( + f"Vocabulary with prefix '{vocabulary.prefix}' already registered" + ) + if vocabulary.iri in self._by_iri: + raise DuplicateVocabularyError( + f"Vocabulary with IRI '{vocabulary.iri}' already registered" + ) + self._by_prefix[vocabulary.prefix] = vocabulary + self._by_iri[vocabulary.iri] = vocabulary + insort(self._sorted_prefixes, vocabulary.prefix) + _log.debug( + "vocabulary_registry.registered", + prefix=vocabulary.prefix, + iri=vocabulary.iri, + num_classes=len(vocabulary.classes), + num_properties=len(vocabulary.properties), + ) + + def unregister(self, prefix: str) -> bool: + """Remove a vocabulary by prefix. + + Args: + prefix: The prefix of the vocabulary to remove. + + Returns: + ``True`` if the vocabulary was removed, ``False`` if the + prefix was not registered. + """ + vocab = self._by_prefix.pop(prefix, None) + if vocab is None: + return False + self._by_iri.pop(vocab.iri, None) + self._sorted_prefixes.remove(prefix) + _log.debug("vocabulary_registry.unregistered", prefix=prefix) + return True + + def get_by_prefix(self, prefix: str) -> ParadigmVocabulary | None: + """Look up a vocabulary by its short prefix. + + Args: + prefix: Short prefix (e.g., ``"uko-oo:"``). + + Returns: + The matching vocabulary, or None if not found. + """ + return self._by_prefix.get(prefix) + + def get_by_iri(self, iri: str) -> ParadigmVocabulary | None: + """Look up a vocabulary by its full IRI. + + Args: + iri: Full namespace IRI. + + Returns: + The matching vocabulary, or None if not found. + """ + return self._by_iri.get(iri) + + def list_prefixes(self) -> tuple[str, ...]: + """Return all registered prefixes in sorted order.""" + return tuple(self._sorted_prefixes) + + def list_all(self) -> tuple[ParadigmVocabulary, ...]: + """Return all registered vocabularies sorted by prefix.""" + return tuple(self._by_prefix[p] for p in self._sorted_prefixes) + + def __len__(self) -> int: + """Return the number of registered vocabularies.""" + return len(self._by_prefix) + + def __contains__(self, item: object) -> bool: + """Check if a prefix or IRI is registered.""" + if isinstance(item, str): + return item in self._by_prefix or item in self._by_iri + return False diff --git a/src/cleveragents/domain/models/acms/__init__.py b/src/cleveragents/domain/models/acms/__init__.py index 814be746b..7cf074395 100644 --- a/src/cleveragents/domain/models/acms/__init__.py +++ b/src/cleveragents/domain/models/acms/__init__.py @@ -5,6 +5,9 @@ Abstraction Layer (BAL) protocols used by actors, skills, strategies, and the Context Assembly Pipeline: CRP types (from :mod:`~cleveragents.domain.models.acms.crp`): +- ``DetailDepth`` -- Type alias for ``int | str`` detail depth values +- ``DetailLevelError`` -- Raised when a detail level name cannot be resolved +- ``DetailLevelCycleError`` -- Raised when a circular parent chain is detected - ``DetailLevelMap`` -- Named-level-to-integer resolution with inheritance - ``ContextRequest`` -- Structured request for context via the CRP - ``ContextFragment`` -- Atomic unit of retrieved context @@ -120,6 +123,9 @@ from cleveragents.domain.models.acms.crp import ( ContextBudget, ContextFragment, ContextRequest, + DetailDepth, + DetailLevelCycleError, + DetailLevelError, DetailLevelMap, FragmentProvenance, ) @@ -226,6 +232,9 @@ __all__: list[str] = [ "ContextStrategy", "ContextStrategyResult", "ContextTier", + "DetailDepth", + "DetailLevelCycleError", + "DetailLevelError", "DetailLevelMap", "DockerComposeAnalyzer", "DomainDescriptor", diff --git a/src/cleveragents/domain/models/acms/crp.py b/src/cleveragents/domain/models/acms/crp.py index fe603baf5..f904afa77 100644 --- a/src/cleveragents/domain/models/acms/crp.py +++ b/src/cleveragents/domain/models/acms/crp.py @@ -16,141 +16,45 @@ Context Assembly Pipeline and produced by context strategies. | ``ContextBudget`` | Token budget management with reservation | | ``AssembledContext`` | Fused, budget-respecting context payload | -## DetailDepth - -At the universal level (UKO Layer 0), detail depth is a **non-negative -integer** -- 0 is the most minimal representation and each increment -reveals more. There is no fixed upper bound; the maximum meaningful -depth depends on the domain and the complexity of the information unit. - -Each UKO domain extension registers a ``DetailLevelMap`` -- a table of -named labels mapped to specific integer depths. Maps are inherited: -a child map includes all entries from its parent map, and may insert -additional levels at any integer position. +``DetailLevelMap``, ``DetailDepth``, ``DetailLevelError``, and +``DetailLevelCycleError`` are defined in the ``detail_level`` sibling +module and re-exported here for backward compatibility. Based on ``docs/specification.md`` ACMS / CRP sections and ADR-014. """ from __future__ import annotations -from typing import Any +from typing import Annotated, Any from pydantic import ( BaseModel, ConfigDict, Field, + StringConstraints, field_validator, model_validator, ) +from cleveragents.domain.models.acms.detail_level import ( # re-exported + DetailDepth, + DetailLevelCycleError, + DetailLevelError, + DetailLevelMap, +) from cleveragents.domain.models.core.project import TemporalScope -# --------------------------------------------------------------------------- -# DetailLevelMap -# --------------------------------------------------------------------------- - - -class DetailLevelMap(BaseModel): - """Maps named detail levels to integer depths for a UKO domain. - - Inherited: a child map includes all entries from its parent map, - and may insert additional levels at any integer position. - - Example:: - - code_map = DetailLevelMap( - domain="uko-code:", - parent=None, - levels={"MODULE_LISTING": 0, "FULL_SOURCE": 9}, - max_depth=9, - ) - assert code_map.resolve(4) == 4 - assert code_map.resolve("MODULE_LISTING") == 0 - """ - - domain: str = Field( - ..., - min_length=1, - description="UKO namespace (e.g., 'uko-code:', 'uko-py:')", - ) - parent: DetailLevelMap | None = Field( - default=None, - description="Parent map to inherit from", - ) - levels: dict[str, int] = Field( - default_factory=dict, - description="Named level -> integer depth", - ) - max_depth: int = Field( - ..., - ge=0, - description="Maximum meaningful depth for this domain", - ) - - @field_validator("levels") - @classmethod - def validate_levels( - cls: type[DetailLevelMap], - v: dict[str, int], - ) -> dict[str, int]: - """Ensure all level values are non-negative integers.""" - for name, depth in v.items(): - if depth < 0: - raise ValueError(f"Detail level '{name}' has negative depth {depth}") - return v - - def resolve(self, depth: int | str) -> int: - """Resolve a named level or integer to an integer depth. - - Args: - depth: Either a raw integer (clamped to ``max_depth``) or - a named level string looked up in this map, then in - parent maps. - - Returns: - Resolved integer depth. - - Raises: - ValueError: If *depth* is a string not found in this map - or any ancestor. - """ - if isinstance(depth, int): - return min(depth, self.max_depth) - # Look up named level in this map, then parent maps - if depth in self.levels: - return self.levels[depth] - if self.parent is not None: - return self.parent.resolve(depth) - raise ValueError(f"Unknown detail level: {depth}") - - def register(self, name: str, value: int) -> None: - """Register a custom detail level. - - Args: - name: Named level label. - value: Integer depth to map to. - - Raises: - ValueError: If *value* is negative. - """ - if value < 0: - raise ValueError(f"Detail level '{name}' has negative depth {value}") - self.levels[name] = value - - def effective_levels(self) -> dict[str, int]: - """Return the full merged level map including inherited entries. - - Parent entries are included first; child entries override any - collisions. - """ - merged = self.parent.effective_levels() if self.parent is not None else {} - merged.update(self.levels) - return merged - - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - ) +__all__: list[str] = [ + "AssembledContext", + "ContextBudget", + "ContextFragment", + "ContextRequest", + "DetailDepth", + "DetailLevelCycleError", + "DetailLevelError", + "DetailLevelMap", + "FragmentProvenance", +] # --------------------------------------------------------------------------- @@ -286,6 +190,19 @@ class ContextFragment(BaseModel, frozen=True): # ContextRequest # --------------------------------------------------------------------------- +#: Upper bound for list fields on ``ContextRequest`` to prevent +#: memory exhaustion from excessively large payloads. +_MAX_LIST_ITEMS: int = 500 + +#: Upper bound for the natural-language ``query`` string. +_MAX_QUERY_LENGTH: int = 10_000 + +#: Upper bound for individual string items inside list fields. +_MAX_ITEM_LENGTH: int = 2_000 + +#: Constrained string type for list items in ``ContextRequest``. +_BoundedStr = Annotated[str, StringConstraints(max_length=_MAX_ITEM_LENGTH)] + class ContextRequest(BaseModel): """A structured request for context, issued by an actor or skill. @@ -301,20 +218,24 @@ class ContextRequest(BaseModel): # === What to find === query: str | None = Field( default=None, + max_length=_MAX_QUERY_LENGTH, description="Natural language query", ) - entities: list[str] = Field( + entities: list[_BoundedStr] = Field( default_factory=list, + max_length=_MAX_LIST_ITEMS, description="Named entities to focus on", ) - uko_types: list[str] = Field( + uko_types: list[_BoundedStr] = Field( default_factory=list, + max_length=_MAX_LIST_ITEMS, description="UKO types to filter", ) # === Scope control === - focus: list[str] = Field( + focus: list[_BoundedStr] = Field( default_factory=list, + max_length=_MAX_LIST_ITEMS, description="URIs or identifiers of specific items to focus on", ) breadth: int = Field( @@ -322,7 +243,7 @@ class ContextRequest(BaseModel): ge=0, description="How many hops outward in the dependency/reference graph", ) - depth: int | str = Field( + depth: DetailDepth = Field( default=3, description=( "How much detail to include for each item found. " @@ -351,12 +272,14 @@ class ContextRequest(BaseModel): ) # === Strategy hints === - preferred_strategies: list[str] = Field( + preferred_strategies: list[_BoundedStr] = Field( default_factory=list, + max_length=_MAX_LIST_ITEMS, description="Preferred context strategies to use", ) - required_backends: list[str] = Field( + required_backends: list[_BoundedStr] = Field( default_factory=list, + max_length=_MAX_LIST_ITEMS, description="Required data backends (text, vector, graph)", ) @@ -369,6 +292,7 @@ class ContextRequest(BaseModel): ) purpose: str = Field( default="", + max_length=_MAX_ITEM_LENGTH, description="Why is this context needed?", ) @@ -376,8 +300,8 @@ class ContextRequest(BaseModel): @classmethod def validate_depth( cls: type[ContextRequest], - v: int | str, - ) -> int | str: + v: DetailDepth, + ) -> DetailDepth: """Ensure integer depths are non-negative.""" if isinstance(v, int) and v < 0: raise ValueError("depth must be non-negative when specified as integer") diff --git a/src/cleveragents/domain/models/acms/detail_level.py b/src/cleveragents/domain/models/acms/detail_level.py new file mode 100644 index 000000000..53d322e71 --- /dev/null +++ b/src/cleveragents/domain/models/acms/detail_level.py @@ -0,0 +1,293 @@ +"""Detail-level map and resolution for UKO domains. + +``DetailLevelMap`` maps named detail levels to integer depths, supports +parent-chain inheritance, cycle detection, and immutable level storage +via ``MappingProxyType``. + +## DetailDepth + +At the universal level (UKO Layer 0), detail depth is a **non-negative +integer** -- 0 is the most minimal representation and each increment +reveals more. Each UKO domain extension registers a ``DetailLevelMap`` +that maps named labels to specific integer depths. Maps are inherited: +a child map includes all entries from its parent map. + +Based on ``docs/specification.md`` ACMS / CRP sections and ADR-014. +""" + +from __future__ import annotations + +import copy +import threading +from collections.abc import Mapping +from types import MappingProxyType +from typing import Any + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + PrivateAttr, + field_serializer, + field_validator, + model_validator, +) + +# --------------------------------------------------------------------------- +# Public type alias +# --------------------------------------------------------------------------- + +#: Detail depth can be a raw non-negative integer **or** a named level +#: string (e.g., ``"SIGNATURES"``) resolved via the active +#: ``DetailLevelMap``. +DetailDepth = int | str + +# --------------------------------------------------------------------------- +# Domain-specific exceptions +# --------------------------------------------------------------------------- + + +class DetailLevelError(ValueError): + """Raised when a detail level name cannot be resolved.""" + + +class DetailLevelCycleError(DetailLevelError): + """Raised when a circular parent chain is detected.""" + + +# --------------------------------------------------------------------------- +# DetailLevelMap +# --------------------------------------------------------------------------- + + +class DetailLevelMap(BaseModel): + """Maps named detail levels to integer depths for a UKO domain. + + Inherited: a child map includes all entries from its parent map, + and may insert additional levels at any integer position. + + The ``levels`` field is stored as a ``MappingProxyType`` to prevent + accidental mutation of module-level constant maps. Use + ``register()`` to add levels after construction (creates a new + immutable snapshot each time). + + Example:: + + code_map = DetailLevelMap( + domain="uko-code:", + parent=None, + levels={"MODULE_LISTING": 0, "FULL_SOURCE": 9}, + max_depth=9, + ) + assert code_map.resolve(4) == 4 + assert code_map.resolve("MODULE_LISTING") == 0 + """ + + domain: str = Field( + ..., + min_length=1, + description="UKO namespace (e.g., 'uko-code:', 'uko-py:')", + ) + parent: DetailLevelMap | None = Field( + default=None, + description="Parent map to inherit from", + ) + levels: Mapping[str, int] = Field( + default_factory=dict, + description=( + "Named level -> integer depth. " + "Accepts a ``dict`` on construction but is stored at runtime " + "as a ``MappingProxyType`` (read-only view) by the " + "``_freeze_levels`` model validator." + ), + ) + max_depth: int = Field( + ..., + ge=0, + description="Maximum meaningful depth for this domain", + ) + + @field_validator("levels", mode="before") + @classmethod + def validate_levels( + cls: type[DetailLevelMap], + v: Mapping[str, int], + ) -> dict[str, int]: + """Validate non-negative depths and normalise to a plain dict. + + Accepts any ``Mapping`` (``dict``, ``MappingProxyType``, etc.) + on construction and during ``validate_assignment`` round-trips. + Always returns a plain ``dict`` for the model validator to freeze. + """ + plain = dict(v) + for name, depth in plain.items(): + if depth < 0: + raise ValueError(f"Detail level '{name}' has negative depth {depth}") + return plain + + @model_validator(mode="after") + def _freeze_levels(self) -> DetailLevelMap: + """Replace the mutable levels dict with a read-only proxy. + + Runs on initial construction and on every ``validate_assignment`` + cycle (Pydantic 2.12+ re-runs model validators on assignment). + The ``__setattr__`` override provides an additional + defense-in-depth safety net. + """ + object.__setattr__(self, "levels", MappingProxyType(dict(self.levels))) + return self + + def __setattr__(self, name: str, value: Any) -> None: + """Re-freeze ``levels`` on assignment as a defensive measure. + + Pydantic 2.12+ re-runs both field *and* model validators on + ``validate_assignment``, so the ``_freeze_levels`` model + validator already handles the normal case. This override is + kept as defense-in-depth in case the Pydantic behavior changes + in a future release. It re-freezes from the validated + ``self.levels`` (not the raw *value*) so any normalisation + applied by the field validator is preserved. + """ + super().__setattr__(name, value) + if ( + name == "levels" + and isinstance(self.levels, (dict, Mapping)) + and not isinstance(self.levels, MappingProxyType) + ): + object.__setattr__(self, "levels", MappingProxyType(dict(self.levels))) + + def __copy__(self) -> DetailLevelMap: + """Support ``copy.copy()`` despite unpicklable MappingProxyType. + + Constructs a new instance directly (not via ``model_copy``) + to avoid infinite recursion: ``model_copy(deep=False)`` + internally calls ``__copy__``. + """ + return DetailLevelMap( + domain=self.domain, + parent=self.parent, + levels=dict(self.levels), + max_depth=self.max_depth, + ) + + def __deepcopy__(self, memo: dict[int, Any] | None = None) -> DetailLevelMap: + """Support ``copy.deepcopy()`` despite unpicklable fields. + + Constructs a new instance directly (not via ``model_copy``) + to avoid infinite recursion. The ``_register_lock`` + (a ``PrivateAttr``) gets a fresh instance automatically. + """ + if memo is None: + memo = {} + return DetailLevelMap( + domain=self.domain, + parent=copy.deepcopy(self.parent, memo), + levels=copy.deepcopy(dict(self.levels), memo), + max_depth=self.max_depth, + ) + + @field_serializer("levels") + @classmethod + def _serialize_levels( + cls: type[DetailLevelMap], + v: Mapping[str, int], + ) -> dict[str, int]: + """Serialize MappingProxyType back to a plain dict for JSON.""" + return dict(v) + + def resolve(self, depth: DetailDepth) -> int: + """Resolve a named level or integer to an integer depth. + + Args: + depth: Either a raw integer (clamped to ``max_depth``) or + a named level string looked up in this map, then in + parent maps. + + Returns: + Resolved integer depth. + + Raises: + ValueError: If *depth* is a string not found in this map + or any ancestor, or if a cycle is detected in the + parent chain. + """ + if isinstance(depth, int): + return max(0, min(depth, self.max_depth)) + visited: set[int] = set() + current: DetailLevelMap | None = self + while current is not None: + map_id = id(current) + if map_id in visited: + raise DetailLevelCycleError( + f"Cycle detected in DetailLevelMap parent chain " + f"while resolving '{depth}'" + ) + visited.add(map_id) + if depth in current.levels: + return current.levels[depth] + current = current.parent + raise DetailLevelError(f"Unknown detail level: {depth}") + + #: Per-instance lock for ``register()`` thread safety. + _register_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) + + def register(self, name: str, value: int) -> None: + """Register a custom detail level. + + Creates a new immutable snapshot of the levels mapping. + Thread-safe via an internal lock. + + Args: + name: Named level label. + value: Integer depth to map to. + + Raises: + ValueError: If *value* is negative or exceeds ``max_depth``. + """ + if value < 0: + raise ValueError(f"Detail level '{name}' has negative depth {value}") + if value > self.max_depth: + raise ValueError( + f"Detail level '{name}' depth {value} exceeds " + f"max_depth {self.max_depth}" + ) + with self._register_lock: + updated = dict(self.levels) + updated[name] = value + object.__setattr__(self, "levels", MappingProxyType(updated)) + + def effective_levels( + self, *, _visited: set[int] | None = None + ) -> MappingProxyType[str, int]: + """Return the full merged level map including inherited entries. + + Returns an immutable ``MappingProxyType`` so callers cannot + accidentally mutate inherited level data. Parent entries are + included first; child entries override any collisions. + + Raises: + DetailLevelCycleError: If a circular parent chain is + detected. + """ + if _visited is None: + _visited = set() + map_id = id(self) + if map_id in _visited: + raise DetailLevelCycleError( + "Cycle detected in DetailLevelMap parent chain " + "during effective_levels()" + ) + _visited.add(map_id) + merged: dict[str, int] = ( + dict(self.parent.effective_levels(_visited=_visited)) + if self.parent is not None + else {} + ) + merged.update(self.levels) + return MappingProxyType(merged) + + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True, + arbitrary_types_allowed=True, + )