diff --git a/README.md b/README.md index 79d90ef..b77679e 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,12 @@ knowledge boundary a first-class object — one that can be queried, audited, an - [Callbacks](#callbacks) - [`on_trace`](#on_trace) - [`on_policy`](#on_policy) - - [`on_learn`](#on_learn) -- [Auto-Learning](#auto-learning) +- [Learning](#learning) - [How It Works](#how-it-works-1) - [Candidate Types](#candidate-types) - - [Decisions and Provenance](#decisions-and-provenance) - - [Two-Phase Edge Placement](#two-phase-edge-placement) - - [The `LearningCallback` ABC](#the-learningcallback-abc) + - [Provenance](#provenance) + - [Reachability Prerequisites](#reachability-prerequisites) + - [Reference Loop](#reference-loop) - [Policy System](#policy-system) - [Defining Policies](#defining-policies) - [Policy Callbacks](#policy-callbacks) @@ -341,10 +340,14 @@ vre_guard( min_depth=None, # DepthLevel | None — enforces a minimum depth floor on_trace=None, # Callable[[GroundingResult], None] on_policy=None, # Callable[[list[PolicyViolation]], bool] - on_learn=None, # LearningCallback — auto-learning loop for knowledge gaps ) ``` +The guard does not orchestrate learning. When grounding fails, it returns the +`GroundingResult` and lets the integrator decide what to do next — typically +by exposing a separate `learn_gaps` tool that the agent can invoke. See +[Learning](#learning). + **`concepts`** can be static or dynamic. Static is appropriate when a function always touches the same concept domain. Dynamic is appropriate when the concepts depend on the actual arguments — for example, a shell tool that must inspect the command string: @@ -381,14 +384,12 @@ Each call runs the following sequence: 1. **Resolve concepts** — map names to canonical primitives via the graph 2. **Ground** — verify the subgraph meets depth requirements (graph-derived + optional `min_depth` floor) 3. **Fire `on_trace`** — surface the epistemic result to the caller -4. **If not grounded and `on_learn` is present** — enter the [auto-learning loop](#auto-learning) -5. **Fire `on_trace` again** — surface the post-learning epistemic result -6. **If still not grounded** — return the `GroundingResult` immediately; the function does not execute -7. **Evaluate policies** — check all `APPLIES_TO` relata for applicable policy gates -8. **If hard blocks** — return `PolicyResult(BLOCK)` immediately; `on_policy` is not consulted -9. **If confirmation required** — call `on_policy` with pending violations; block if declined or no handler -10. **If BLOCK** — return the `PolicyResult`; the function does not execute -11. **Execute** — call the original function and return its result +4. **If not grounded** — return the `GroundingResult` immediately; the function does not execute +5. **Evaluate policies** — check all `APPLIES_TO` relata for applicable policy gates +6. **If hard blocks** — return `PolicyResult(BLOCK)` immediately; `on_policy` is not consulted +7. **If confirmation required** — call `on_policy` with pending violations; block if declined or no handler +8. **If BLOCK** — return the `PolicyResult`; the function does not execute +9. **Execute** — call the original function and return its result --- @@ -459,45 +460,60 @@ If `on_policy` is not provided and a policy requires confirmation, the guard ret image -### `on_learn` - -Called when grounding fails and the guard enters the auto-learning loop. See [Auto-Learning](#auto-learning) for -details. - --- -## Auto-Learning +## Learning -When grounding fails and an `on_learn` callback is present, VRE enters an iterative learning loop that transforms -knowledge gaps into graph growth. Rather than simply blocking the action, VRE surfaces -structured templates for each gap, invokes the callback to fill them, and persists accepted knowledge back to the -graph — then re-grounds to see if the action is now justified. +VRE is a **knowledge linter**, not a knowledge builder. It identifies gaps and validates fills; the integrator +owns the loop. When grounding fails, the integrator decides whether to surface the gaps to the user, escalate +to a human, or run a learning loop that grows the graph through use. -This is VRE's answer to its primary adoption bottleneck: manual graph authoring. The graph grows through use. +This separation is deliberate. Loop orchestration is inherently integration-specific — different LLMs, different +data sources, different retry/budget strategies. By keeping VRE's surface tight (identify gaps, persist fills), +integrators can build whatever flow fits their stack without fighting the framework. ### How It Works -1. **Gap detected** — grounding check reveals one or more knowledge gaps -2. **Template created** — VRE generates a structured candidate template based on the gap type -3. **Callback invoked** — the integrator's `on_learn` callback receives the template, the full grounding result, and the - specific gap. The callback fills the template (via LLM, user input, or any other - mechanism) and returns a decision. -4. **Persistence** — accepted or modified candidates are persisted to the graph with provenance tracking -5. **Re-ground** — VRE re-checks grounding. The gap landscape may have shifted — new gaps may have appeared, existing - ones may be resolved. The loop continues until grounded, all gaps are addressed, or - the user rejects. +VRE exposes three things: + +1. **`vre.check(concepts)`** returns a `GroundingResult` with structured `KnowledgeGap` objects when grounding fails +2. **`template_for_gap(gap)`** returns the candidate model class to fill — the integrator constructs an instance + however they like (LLM structured output, user input, static rules) +3. **`vre.learning_engine.learn_gap(gap, candidate, source=LEARNED)`** validates the candidate against its gap and + persists it to the graph + +A typical integrator-owned loop looks like this: + +```python +from vre.learning.templates import template_for_gap + +grounding = vre.check(["delete", "file"]) +while not grounding.grounded and grounding.gaps: + gap = grounding.gaps[0] + candidate_cls = template_for_gap(gap) + filled = my_llm_fill(candidate_cls, gap, grounding) # integrator's code + if filled is None: + break + vre.learning_engine.learn_gap(gap, filled) + vre.resolver.invalidate() + grounding = vre.check(["delete", "file"]) +``` + +`learn_gap` raises `CandidateValidationError` if the candidate is malformed or if its prerequisites are not met +(e.g. trying to place an edge at a depth the source does not have). The integrator catches the error, fills the +prerequisite, and retries. ### Candidate Types Each gap type has a corresponding candidate model. Candidates carry only what's *new* — all context (primitive IDs, existing depths, required depths) lives on the gap itself. -| Gap Type | Candidate | What the Agent Fills In | -|-------------------|-------------------------|------------------------------------------------------------------------| -| `ExistenceGap` | `ExistenceCandidate` | D1 identity for a new concept (D0 is auto-generated) | -| `DepthGap` | `DepthCandidate` | Missing depth levels with properties | -| `RelationalGap` | `RelationalCandidate` | Missing depth levels on the edge target | -| `ReachabilityGap` | `ReachabilityCandidate` | Edge placement: target name, relation type, source/target depth levels | +| Gap Type | Candidate | What the Integrator Fills In | +|-------------------|-------------------------|-------------------------------------------------------------------------------| +| `ExistenceGap` | `ExistenceCandidate` | D1 identity for a new concept (D0 is auto-generated) | +| `DepthGap` | `DepthCandidate` | Missing depth levels with properties | +| `RelationalGap` | `RelationalCandidate` | Missing depth levels on the edge target | +| `ReachabilityGap` | `ReachabilityCandidate` | Edge placement: source name, target name, relation type, source/target depths | `ExistenceCandidate`, `DepthCandidate`, and `RelationalCandidate` all use `ProposedDepth`: @@ -510,71 +526,41 @@ ProposedDepth( ) ``` -### Decisions and Provenance - -The callback returns one of four decisions, and provenance is derived from what actually happened: +`ReachabilityCandidate` declares both source and target by name. At least one of them must match the gap's +primitive — the edge must fix *this* disconnection — but the integrator chooses the direction. An edge from an +existing connected node *back* to the orphan is just as valid as one originating from the orphan. -| Decision | Effect | Provenance | -|------------|------------------------------------------------------|------------------| -| `ACCEPTED` | Persist as proposed | `learned` | -| `MODIFIED` | Persist after user refinement | `conversational` | -| `SKIPPED` | Intentionally dismissed — loop continues to next gap | — | -| `REJECTED` | Discard — stops the learning loop entirely | — | +### Provenance -`SKIPPED` is particularly important for reachability gaps: the absence of an edge can itself be an enforcement -mechanism. If a concept *should not* be connected, the user skips rather than placing an -edge. +`learn_gap` accepts an optional `source: ProvenanceSource` parameter (default `LEARNED`). The integrator decides +how to stamp persisted knowledge based on its own loop semantics — `LEARNED` for LLM-proposed fills accepted as-is, +`CONVERSATIONAL` for human-modified proposals, etc. The graph remembers not just what it knows, but how it came to +know it. -### Two-Phase Edge Placement +### Reachability Prerequisites -Reachability candidates focus solely on edge placement — they declare *where* the edge goes, not what depths need to -exist. If the source or target lacks the declared depth level, the engine -automatically synthesizes a `DepthGap` and invokes the callback to learn the missing depths *before* placing the edge. -This keeps each candidate type focused on its single concern while handling -cascading dependencies naturally. +`ReachabilityCandidate` focuses solely on edge placement — it declares *where* the edge goes, not what depths need +to exist. If the source or target lacks the required depth level, `learn_gap` raises `CandidateValidationError`. -### The `LearningCallback` ABC +To handle this cleanly, the engine exposes `reachability_prerequisites(gap, candidate)` which returns a list of +`DepthGap` objects that must be filled before the edge can be placed. The integrator's loop checks prerequisites, +fills them, and only then calls `learn_gap` for the reachability candidate. ```python -from vre.learning.callback import LearningCallback -from vre.learning.models import LearningCandidate, CandidateDecision - - -class MyLearner(LearningCallback): - def __call__(self, candidate, grounding, gap) -> tuple[LearningCandidate | None, CandidateDecision]: - # Fill the template, present to user, return (filled, decision) - ... +prereqs = vre.learning_engine.reachability_prerequisites(gap, filled) +for depth_gap in prereqs: + depth_filled = my_llm_fill(template_for_gap(depth_gap), depth_gap, grounding) + vre.learning_engine.learn_gap(depth_gap, depth_filled) +vre.learning_engine.learn_gap(gap, filled) ``` -`LearningCallback` is an abstract base class with `__call__` as the only required method. It also supports context -manager lifecycle via `__enter__` and `__exit__` (default no-ops) — `learn_all` wraps -the session in `with callback:`, allowing callbacks to manage state across a learning session. - -### Example - -The following walkthrough uses the `seed_gaps` script and attempts to create and write to a file. The learning loop -resolves several knowledge gaps through agent-user conversational turns: - -1. **Existence Gap** — `write` did not exist in the graph -2. **Reachability Gap** — no edges connecting `write` and `file` -3. **Depth Gaps** — both `write` and `file` were missing the depths required by the edge placement - -image +### Reference Loop -image - -image - -image - -Of note: the agent correctly identified additional relata that should be attached to the `File` primitive and attempted -to record them in the `properties` object. This indicates the agent is reasoning -from within the epistemic envelope defined by the grounding trace, using neighboring primitives in the subgraph to -enrich its own proposals. - -The repository includes a reference `DemoLearner` implementation (`examples/langchain_ollama/learner.py`) that uses -ChatOllama structured output to fill templates and Rich to present proposals. The -user chooses: accept, modify (provide feedback, LLM re-proposes), skip, or reject. +The repository includes a reference `learn_gaps` tool (`examples/langchain_ollama/tools.py`) and a `DemoLearner` +(`examples/langchain_ollama/learner.py`) that exercise the full pattern: ChatOllama structured output for filling +candidates, Rich panels for human review, accept/modify/skip/reject decisions, and prerequisite handling for +reachability gaps. The langchain agent gets the `learn_gaps` tool alongside its primary tools — when a guarded +command is blocked, the agent calls `learn_gaps` to resolve the gaps and retries. --- @@ -752,18 +738,18 @@ concepts = ConceptExtractor() @vre_guard( vre, concepts=concepts, # LLM extracts primitives from command string - cardinality=get_cardinality, - # inspects flags/globs -> "single" or "multiple" + cardinality=get_cardinality, # inspects flags/globs -> "single" or "multiple" on_trace=on_trace, # renders epistemic tree to terminal - on_policy=on_policy, - # Rich Confirm.ask prompt - on_learn=on_learn, # auto-learning callback for knowledge gaps + on_policy=on_policy, # Rich Confirm.ask prompt ) def shell_tool(command: str) -> str: result = subprocess.run(command, shell=True, capture_output=True, text=True, cwd=sandbox) return result.stdout + result.stderr ``` +The agent is given two tools: `shell_tool` (guarded) and `learn_gaps` (the integrator-owned learning loop). +When the guarded shell_tool blocks on knowledge gaps, the agent invokes `learn_gaps` to resolve them and retries. + ### Claude Code Hook `examples/claude-code/` contains a [PreToolUse hook](https://docs.anthropic.com/en/docs/claude-code/hooks) @@ -862,10 +848,10 @@ grounding history, and affect the agent's confidence in related concepts. ``` src/vre/ -├── __init__.py # VRE public interface (check, learn_all, check_policy) -├── guard.py # vre_guard decorator (grounding → learning → policy → execution) -├── metrics.py # MetricsManager — best-effort grounding/learning metric updates -├── tracing.py # TraceWriter + TraceManager — JSONL persistence + suppression +├── __init__.py # VRE public interface (check, check_policy, learning_engine) +├── guard.py # vre_guard decorator (grounding → policy → execution) +├── metrics.py # MetricsManager — best-effort grounding metric updates +├── tracing.py # TraceWriter + TraceManager — JSONL persistence │ ├── identity/ │ ├── models.py # AgentIdentity — stable UUID bound to a registration key @@ -886,10 +872,9 @@ src/vre/ │ └── wizard.py # Interactive policy attachment CLI │ └── learning/ - ├── callback.py # LearningCallback ABC - ├── models.py # Candidate models, CandidateDecision, LearningResult - ├── templates.py # template_for_gap — gap → structured candidate template - └── engine.py # LearningEngine — template → callback → validate → persist + ├── models.py # Candidate models with validate_for_gap methods + ├── templates.py # template_for_gap — gap → candidate model class + └── engine.py # LearningEngine — learn_gap, reachability_prerequisites scripts/ ├── clear_graph.py # Clear all primitives from the Neo4j graph @@ -902,7 +887,7 @@ examples/ └── langchain_ollama/ ├── main.py # Entry point — argparse + agent setup ├── agent.py # ToolAgent — LangChain + Ollama streaming loop - ├── tools.py # shell_tool with vre_guard applied + ├── tools.py # shell_tool (guarded) and learn_gaps (integrator loop) ├── callbacks.py # ConceptExtractor, on_trace, on_policy, get_cardinality ├── policies.py # Demo PolicyCallback — protected file deletion guard ├── learner.py # DemoLearner — ChatOllama structured output + Rich UI diff --git a/docs/index.html b/docs/index.html index 4d97337..3a9d667 100644 --- a/docs/index.html +++ b/docs/index.html @@ -774,9 +774,12 @@

- Depth is monotonic: D3 grounding implies D0–D2. - Planning requires D2. Execution requires D3. Execution without D3 - grounding is forbidden. + Depth is monotonic: D3 grounding implies D0–D2 are + also grounded. Depth requirements are derived from the graph itself — + edges carry a source depth that determines when they become visible + and a target depth that determines when they resolve. Integrators can + enforce a minimum depth floor (e.g. D3 for execution) as a secondary + safety lever via min_depth.

@@ -875,16 +878,19 @@

Each vre_guard call follows a strict sequence: resolve concept names to canonical primitives → ground the subgraph against depth requirements → fire - on_trace with the result → if not grounded and - on_learn is provided, enter the - learning loop to resolve gaps → re-ground and fire - on_trace again → if still not grounded, + on_trace with the result → if not grounded, block and return gaps → evaluate policies on APPLIES_TO relata → if hard blocks, block immediately → if confirmation required, call on_policy for human confirmation → if approved, execute the original function.

+

+ The guard does not orchestrate learning. When grounding fails, it + returns the gaps and lets the integrator decide what to do — typically + by exposing a separate learn_gaps tool the agent can + invoke. See LEARNING. +

THE THREE LAYERS @@ -955,7 +961,6 @@

    min_depth=None,   # DepthLevel | None — floor override
    on_trace=None,    # Callable[[GroundingResult], None]
    on_policy=None,   # Callable[[list[PolicyViolation]], bool]
-     on_learn=None,    # LearningCallback | None
)
# Static concepts
@@ -1048,51 +1050,72 @@

threeColor: 0xF0C54D, content: { depth: 'LEARNING', - title: 'Ignorance is surfaced. Knowledge is earned.', + title: 'VRE is a knowledge linter.', body: `

When grounding fails, VRE does not simply block the agent. It surfaces - structured knowledge gaps that describe exactly what is - missing. The auto-learning loop turns these gaps into proposals that a - human reviews before anything enters the graph. + structured knowledge gaps that describe exactly what + is missing — and provides the tools to fill them. But VRE does not + orchestrate the loop. The integrator owns it. +

+

+ This separation is deliberate. Loop orchestration is inherently + integration-specific — different LLMs, different data sources, + different retry/budget strategies. By keeping VRE's surface tight + (identify gaps, persist fills), integrators can build whatever flow + fits their stack without fighting the framework.

- THE LEARNING LOOP + VRE'S LEARNING SURFACE

- Each gap type produces a candidate template that an - agent fills in. The human reviews, modifies, or rejects the proposal. - Accepted candidates are persisted with provenance tracking. + Three things, total: +

+
    +
  • + vre.check(concepts) returns a GroundingResult with structured KnowledgeGap objects when grounding fails. +
  • +
  • + template_for_gap(gap) returns the candidate model class to fill — the integrator constructs an instance however they like (LLM structured output, user input, static rules). +
  • +
  • + vre.learning_engine.learn_gap(gap, candidate) validates the candidate against its gap and persists it to the graph. +
  • +
+

+ + INTEGRATOR-OWNED LOOP +

1
-
GAP DETECTED
-
Grounding fails → structured gap surfaced
+
CHECK
+
Integrator calls vre.check() → gets gaps
2
-
TEMPLATE CREATED
-
Engine builds a candidate from the gap type
+
FILL
+
Integrator fills the candidate (LLM, user, rules — their call)
3
-
AGENT PROPOSES
-
LLM fills the template with domain knowledge
+
PERSIST
+
VRE validates and persists via learn_gap
4
-
HUMAN REVIEWS
-
Accept, modify, skip, or reject the proposal
+
RE-CHECK
+
Integrator re-checks; loop continues until grounded
@@ -1103,7 +1126,7 @@

  • - ExistenceCandidate — concept not in graph. Agent proposes D1 (identity); D0 is auto-generated on acceptance. + ExistenceCandidate — concept not in graph. Integrator proposes D1 (identity); D0 is auto-generated on acceptance.
  • DepthCandidate — concept exists but is too shallow. Proposes missing depths. @@ -1112,20 +1135,22 @@

    RelationalCandidate — relatum target is under-grounded. Proposes depths on target.
  • - ReachabilityCandidate — concept is disconnected. Proposes edge placement. + ReachabilityCandidate — concept is disconnected. Proposes edge placement, in either direction.

- TWO-PHASE EDGE PLACEMENT + REACHABILITY PREREQUISITES

- When a reachability gap is resolved by placing an edge, the engine - checks whether the source and target have the required depths. If not, - it synthesizes DepthGaps and invokes the learning callback - for each missing depth before placing the edge. The - agent cannot connect concepts it does not yet understand. + Reachability candidates focus on edge placement, not depth. If the + source or target lacks the required depth, learn_gap + raises CandidateValidationError. The engine exposes + reachability_prerequisites(gap, candidate) which returns + the DepthGaps that must be filled first. The integrator + fills them, then places the edge — explicitly, with no nested + callbacks.

@@ -1133,10 +1158,11 @@

- Every piece of learned knowledge carries structured provenance. - Accepted proposals are marked learned. Modified proposals - are marked conversational. The graph remembers not just - what it knows, but how it came to know it. + Every piece of persisted knowledge carries structured provenance. + learn_gap accepts an optional source + parameter — the integrator decides how to stamp fills based on its + own loop semantics. The graph remembers not just what it knows, but + how it came to know it.

` } diff --git a/examples/langchain_ollama/agent.py b/examples/langchain_ollama/agent.py index ba021e7..0536935 100644 --- a/examples/langchain_ollama/agent.py +++ b/examples/langchain_ollama/agent.py @@ -4,15 +4,26 @@ from __future__ import annotations -from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage from langchain_ollama import ChatOllama SYSTEM = """\ -You are a helpful assistant with access to tools for filesystem management. Every tool is gated by -VRE (Volute Reasoning Engine) to enforce epistemic justification before execution. +You are a filesystem assistant. You have two tools: -Your shell tool runs in a fully writable workspace directory. All files should be created and managed -there using relative paths. Do not use /tmp or other directories outside the workspace. +1. shell_tool — runs a shell command in the workspace directory. Use relative paths. +2. learn_gaps — resolves knowledge gaps so blocked commands can proceed. + +Every shell command is checked by VRE (Volute Reasoning Engine) before execution. +If VRE does not have enough knowledge about the concepts involved, the command is +blocked and the tool returns a grounding result listing the gaps. + +When a shell command is blocked: +- Read the gaps in the response (DEPTH, REACHABILITY, RELATIONAL, EXISTENCE). +- Call learn_gaps with ALL the concept names from the blocked command (comma-separated). +- After learn_gaps resolves the gaps, retry the original shell command. + +Do not assume a tool call succeeded unless you see its result. Do not narrate actions +you have not taken. Call tools, read results, then respond. """ @@ -26,8 +37,14 @@ class ToolAgent: content (including blocks) appears before the tool fires. """ - def __init__(self, tools: list, model: str = "qwen3:8b") -> None: - self._llm = ChatOllama(model=model, reasoning=True).bind_tools(tools) + def __init__(self, tools: list, model: str = "gemma4:26b") -> None: + self._llm = ChatOllama( + model=model, + reasoning=True, + top_p=0.95, + top_k=64, + temperature=1.0 + ).bind_tools(tools) self._tools = {t.name: t for t in tools} def stream(self, inputs: dict): @@ -54,7 +71,11 @@ def stream(self, inputs: dict): for c in chunks[1:]: full = full + c - messages.append(full) + history_msg = AIMessage( + content=full.content, + tool_calls=full.tool_calls, + ) + messages.append(history_msg) if not full.tool_calls: break diff --git a/examples/langchain_ollama/callbacks.py b/examples/langchain_ollama/callbacks.py index 9c05836..64f5b9a 100644 --- a/examples/langchain_ollama/callbacks.py +++ b/examples/langchain_ollama/callbacks.py @@ -14,7 +14,6 @@ from rich.prompt import Confirm from rich.tree import Tree -from examples.langchain_ollama.learner import DemoLearner from examples.langchain_ollama.repl import console from vre.core.policy.models import PolicyViolation @@ -55,27 +54,27 @@ def __init__(self, model: str = "qwen2.5-coder:7b") -> None: def _format_prompt(command: str) -> str: return f""" Shell command: {command} - + Identify the conceptual primitives this command touches. - + Primitives are the conceptual entities required to reason about the effects of the command. This includes ACTIONS, TARGETS, and concepts implied by flags. - + - Actions: read, write, delete, create, move, copy, list, execute, modify, etc. - Targets: file, directory, process, network, permission, etc. - + Flag-to-concept examples: - `rm -rf dir/` → delete + directory + file - `cp -a src/ dst/` → copy + file + directory + permission - `chmod +x script.sh` → modify + permission + file + execute - `find . -name "*.py" -delete` → list + delete + file + directory - + Flags themselves are NOT primitives, but they change semantic intent or introduce additional concepts as shown above. Do NOT return flag names (recursive, force, verbose, interactive, etc.) as primitives. Map what the flag *does* to the concepts it affects. - + Return only the list of required conceptual primitives. """ @@ -227,10 +226,3 @@ def on_policy(violations: list[PolicyViolation]) -> bool: if not Confirm.ask(f"[yellow]⚠ Policy gate:[/] {v.message}"): return False return True - - -def make_on_learn(model: str = "qwen3:8b") -> DemoLearner: - """ - Create a learning callback for the demo agent. - """ - return DemoLearner(model=model) diff --git a/examples/langchain_ollama/learner.py b/examples/langchain_ollama/learner.py index eaa6a4b..af4f364 100644 --- a/examples/langchain_ollama/learner.py +++ b/examples/langchain_ollama/learner.py @@ -1,8 +1,12 @@ """ -Demo auto-learning module — meta-epistemic dialogue with the agent. +Demo learning module — meta-epistemic dialogue with the agent. Uses ChatOllama structured output to fill candidate templates, then presents proposals to the user via Rich for review. + +This is a reference implementation showing how an integrator can wire up +learning. VRE does not enforce any particular callback interface — the +integrator produces filled candidates however they choose. """ from __future__ import annotations @@ -15,9 +19,7 @@ from rich.prompt import Confirm, Prompt from examples.langchain_ollama.repl import console -from vre.learning.callback import LearningCallback from vre.learning.models import ( - CandidateDecision, DepthCandidate, ExistenceCandidate, LearningCandidate, @@ -47,7 +49,7 @@ Each depth carries `properties` — descriptive attributes intrinsic to the concept at that level. Properties describe what something IS, what -it CAN DO, or what CONDITIONS apply to it. +it CAN DO, or what CONDITIONS apply to it. You are given an epistemic trace showing the current state of the knowledge graph and a gap that needs to be filled. Propose knowledge @@ -107,21 +109,27 @@ """ _REACHABILITY_PROMPT = """\ -The concept '{source}' is not connected to other concepts in the subgraph. +The concept '{orphan}' is not connected to other concepts in the subgraph. + +Available concepts: +{others} -Available targets: -{targets} +Existing depths on '{orphan}': +{orphan_depths} -Existing depths on '{source}': -{source_depths} +Propose an edge connecting '{orphan}' to the graph. The edge can go in +either direction — '{orphan}' can be the source OR the target. Choose +whichever direction is semantically correct. -Propose an edge connecting '{source}' to one of the available targets. You MUST fill in ALL of the following fields: -1. target_name — name of the target concept -2. relation_type — one of: APPLIES_TO, REQUIRES, CONSTRAINED_BY, DEPENDS_ON, INCLUDES -3. source_depth_level — int, which depth on '{source}' to place the edge at -4. target_depth_level — int, the minimum depth required on the target for the edge to resolve +1. source_name — name of the concept the edge originates from +2. target_name — name of the concept the edge points to +3. relation_type — one of: APPLIES_TO, REQUIRES, CONSTRAINED_BY, DEPENDS_ON, INCLUDES +4. source_depth_level — int, which depth on the source to place the edge at +5. target_depth_level — int, the minimum depth required on the target for the edge to resolve + +At least one of source_name or target_name MUST be '{orphan}'. Do NOT propose new depths. Focus only on edge placement. Any missing depths will be handled separately after the edge is placed. @@ -194,9 +202,9 @@ def build_prompt(candidate: LearningCandidate, grounding: GroundingResult, gap) ) if isinstance(candidate, ReachabilityCandidate): return _REACHABILITY_PROMPT.format( - source=gap.primitive.name, - targets=_fmt_available_targets(grounding, gap.primitive.id), - source_depths=_fmt_existing_depths(gap.primitive), + orphan=gap.primitive.name, + others=_fmt_available_targets(grounding, gap.primitive.id), + orphan_depths=_fmt_existing_depths(gap.primitive), trace=trace, ) return "" @@ -232,14 +240,14 @@ def render_candidate(candidate: LearningCandidate, gap) -> None: f" D{d.level.value} {d.level.name}: {d.properties}" for d in candidate.new_depths ) or " (none)" console.print(Panel( - f"[bold]Source:[/] {gap.source.name} → [bold]Target:[/] {gap.target.name}\n" + f"[bold]Source:[/] {gap.source.name} -> [bold]Target:[/] {gap.target.name}\n" f"[bold]New depths on target:[/]\n{depths_str}", title="[bold yellow]Relational Proposal[/]", border_style="yellow", )) elif isinstance(candidate, ReachabilityCandidate): console.print(Panel( - f"[bold]Source:[/] {gap.primitive.name}\n" + f"[bold]Source:[/] {candidate.source_name or '(none)'}\n" f"[bold]Target:[/] {candidate.target_name or '(none)'}\n" f"[bold]Relation:[/] {candidate.relation_type.value if candidate.relation_type else '(none)'}\n" f"[bold]Source depth:[/] D{candidate.source_depth_level.value if candidate.source_depth_level else '?'}\n" @@ -254,22 +262,21 @@ def render_candidate(candidate: LearningCandidate, gap) -> None: # --------------------------------------------------------------------------- -# DemoLearner — the callback implementation +# DemoLearner — the demo's learning implementation # --------------------------------------------------------------------------- -class DemoLearner(LearningCallback): +class DemoLearner: """ - Learning callback that uses ChatOllama to fill candidate templates - and presents them to the user for review via Rich. + Demo learner that uses ChatOllama to fill candidate templates + and presents them to the user via Rich for review. - Flow: agent proposes → user reviews → accept/modify/skip/reject. - If the user requests modifications, the agent re-proposes with feedback - until the user is satisfied. + Flow: agent proposes -> user reviews -> accept/modify/skip/reject. + Returns the filled candidate or None (skip/reject). """ - def __init__(self, model: str = "qwen3:8b") -> None: - self._model = model + def __init__(self, model: str = "gemma4:26b") -> None: self._active = False + self._llm = ChatOllama(model=model, reasoning=False) def __enter__(self) -> DemoLearner: self._active = False @@ -283,31 +290,29 @@ def _invoke_llm( candidate: LearningCandidate, messages: list[dict[str, str]], ) -> LearningCandidate: - llm = ChatOllama(model=self._model).with_structured_output(type(candidate)) - return llm.invoke(messages) + return self._llm.with_structured_output(type(candidate)).invoke(messages) def __call__( self, candidate: LearningCandidate, grounding: GroundingResult, gap: KnowledgeGap, - ) -> tuple[LearningCandidate | None, CandidateDecision]: + ) -> LearningCandidate | None: if not self._active: if not Confirm.ask("\n[bold cyan]Knowledge gap detected.[/] Enter learning mode?"): - return None, CandidateDecision.REJECTED + return None self._active = True - messages = [ - {"role": "system", "content": _LEARN_SYSTEM}, - {"role": "user", "content": build_prompt(candidate, grounding, gap)}, - ] + gap_prompt = build_prompt(candidate, grounding, gap) console.print("\n[dim]Agent is proposing knowledge...[/]") - filled = self._invoke_llm(candidate, messages) + filled = self._invoke_llm(candidate, [ + {"role": "system", "content": _LEARN_SYSTEM}, + {"role": "user", "content": gap_prompt}, + ]) render_candidate(filled, gap) - was_modified = False while True: choice = questionary.select( "Decision:", @@ -315,20 +320,19 @@ def __call__( default="accept", ).ask() - if choice is None: # Ctrl-C - return None, CandidateDecision.REJECTED - if choice == "accept": - decision = CandidateDecision.MODIFIED if was_modified else CandidateDecision.ACCEPTED - return filled, decision - if choice == "skip": - return None, CandidateDecision.SKIPPED - if choice == "reject": - return None, CandidateDecision.REJECTED - if choice == "modify": - was_modified = True - messages.append({"role": "assistant", "content": filled.model_dump_json()}) - feedback = Prompt.ask("[bold]What should be changed?[/]") - messages.append({"role": "user", "content": feedback}) - console.print("\n[dim]Agent is revising...[/]") - filled = self._invoke_llm(candidate, messages) - render_candidate(filled, gap) + match choice: + case "accept": + return filled + case "modify": + feedback = Prompt.ask("[bold]What should be changed?[/]") + console.print("\n[dim]Agent is revising...[/]") + filled = self._invoke_llm(candidate, [ + {"role": "system", "content": _LEARN_SYSTEM}, + {"role": "user", "content": ( + f"Revise this proposal:\n{filled.model_dump_json()}\n\n" + f"Requested change: {feedback}" + )}, + ]) + render_candidate(filled, gap) + case _: + return None diff --git a/examples/langchain_ollama/main.py b/examples/langchain_ollama/main.py index bbd8f56..6037363 100644 --- a/examples/langchain_ollama/main.py +++ b/examples/langchain_ollama/main.py @@ -16,9 +16,10 @@ from vre.core.graph import PrimitiveRepository from examples.langchain_ollama.agent import make_agent -from examples.langchain_ollama.callbacks import ConceptExtractor, get_cardinality, make_on_learn, on_policy, on_trace +from examples.langchain_ollama.callbacks import ConceptExtractor, get_cardinality, on_policy, on_trace +from examples.langchain_ollama.learner import DemoLearner from examples.langchain_ollama.repl import run -from examples.langchain_ollama.tools import init_tools +from examples.langchain_ollama.tools import init_tools, init_learn_tool def main() -> None: @@ -37,7 +38,8 @@ def main() -> None: vre = VRE(repo, agent_key="langchain-ollama-demo-agent", agent_name="Langchain Ollama Demo Agent") concepts = ConceptExtractor(model=args.concepts_model) - on_learn = make_on_learn(model=args.model) + learner = DemoLearner(model=args.model) + shell_fn = init_tools( vre, args.sandbox, @@ -45,7 +47,6 @@ def main() -> None: get_cardinality, on_trace, on_policy, - on_learn, ) shell_tool = StructuredTool.from_function( shell_fn, @@ -53,7 +54,14 @@ def main() -> None: description="Run a shell command in the workspace directory. The workspace is fully writable — you can create, modify, delete, and execute files here. Use relative paths.", ) - agent = make_agent([shell_tool], model=args.model) + learn_fn = init_learn_tool(vre, learner) + learn_tool = StructuredTool.from_function( + learn_fn, + name="learn_gaps", + description="Identify and resolve knowledge gaps for concepts. Pass a comma-separated list of concept names (e.g. 'delete, file, directory'). Use this when a shell command is blocked due to ungrounded concepts.", + ) + + agent = make_agent([shell_tool, learn_tool], model=args.model) run(agent) diff --git a/examples/langchain_ollama/tools.py b/examples/langchain_ollama/tools.py index 78c89c4..55d7617 100644 --- a/examples/langchain_ollama/tools.py +++ b/examples/langchain_ollama/tools.py @@ -1,5 +1,5 @@ """ -VRE-guarded shell tool for the demo agent. +VRE-guarded shell tool and learn_gaps tool for the demo agent. """ from __future__ import annotations @@ -7,7 +7,10 @@ import subprocess from typing import Callable +from vre.core.models import ReachabilityGap from vre.guard import vre_guard +from vre.learning.models import ReachabilityCandidate +from vre.learning.templates import template_for_gap def init_tools( @@ -17,7 +20,6 @@ def init_tools( cardinality: Callable, on_trace: Callable, on_policy: Callable, - on_learn: Callable | None = None, ): @vre_guard( vre, @@ -25,7 +27,6 @@ def init_tools( cardinality=cardinality, on_trace=on_trace, on_policy=on_policy, - on_learn=on_learn, ) def shell_tool(command: str, cwd: str = sandbox) -> str: """ @@ -40,3 +41,66 @@ def shell_tool(command: str, cwd: str = sandbox) -> str: return result.stdout + result.stderr return shell_tool + + +def init_learn_tool(vre, learner): + """ + Create a learn_gaps tool that the agent can invoke to fill knowledge gaps. + + The loop lives here — not in VRE core. VRE provides check(), + template_for_gap(), and learn_gap(). The integrator (this tool) + decides how to orchestrate them. + """ + def learn_gaps(concepts: str) -> str: + """ + Identify and resolve knowledge gaps for the given concepts. + Pass a comma-separated list of concept names. + """ + concept_list = [c.strip() for c in concepts.split(",")] + grounding = vre.check(concept_list) + if grounding.grounded: + return str(grounding) + + skipped: set[int] = set() + with learner: + while not grounding.grounded and grounding.gaps: + gap_index = next( + (i for i, _ in enumerate(grounding.gaps) if i not in skipped), + None, + ) + if gap_index is None: + break + + gap = grounding.gaps[gap_index] + template = template_for_gap(gap) + filled = learner(template, grounding, gap) + + if filled is None: + skipped.add(gap_index) + continue + + if isinstance(gap, ReachabilityGap) and isinstance(filled, ReachabilityCandidate): + prereqs = vre.learning_engine.reachability_prerequisites(gap, filled) + for depth_gap in prereqs: + depth_template = template_for_gap(depth_gap) + depth_filled = learner(depth_template, grounding, depth_gap) + if depth_filled is None: + break + vre.learning_engine.learn_gap(depth_gap, depth_filled) + else: + vre.learning_engine.learn_gap(gap, filled) + vre.resolver.invalidate() + grounding = vre.check(concept_list) + skipped.clear() + continue + skipped.add(gap_index) + continue + + vre.learning_engine.learn_gap(gap, filled) + vre.resolver.invalidate() + grounding = vre.check(concept_list) + skipped.clear() + + return str(grounding) + + return learn_gaps diff --git a/pyproject.toml b/pyproject.toml index b416bb9..04c31d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vre" -version = "0.5.0" +version = "0.6.0" description = "Volute Reasoning Engine — decorator-based epistemic enforcement" authors = [ {name = "Andrew Greene",email = "anormang@gmail.com"} diff --git a/src/vre/__init__.py b/src/vre/__init__.py index e0a19ca..ebeeb46 100644 --- a/src/vre/__init__.py +++ b/src/vre/__init__.py @@ -42,13 +42,7 @@ from vre.core.policy.callback import PolicyCallContext from vre.core.policy.gate import PolicyGate from vre.identity import AgentIdentity, AgentRegistry -from vre.learning import ( - CandidateDecision, - LearningCallback, - LearningCandidate, - LearningEngine, - LearningResult, -) +from vre.learning import LearningEngine, template_for_gap from vre.metrics import MetricsManager from vre.tracing import TraceManager, TraceWriter @@ -84,11 +78,8 @@ "PolicyViolation", "PolicyCallContext", "PolicyGate", - "CandidateDecision", - "LearningCallback", - "LearningCandidate", "LearningEngine", - "LearningResult", + "template_for_gap", ] @@ -136,6 +127,20 @@ def identity(self) -> AgentIdentity | None: """ return self._identity + @property + def learning_engine(self) -> LearningEngine: + """ + The learning engine for validating and persisting candidate fills. + """ + return self._learning_engine + + @property + def resolver(self) -> ConceptResolver: + """ + The concept resolver for name-to-primitive resolution and cache invalidation. + """ + return self._resolver + def _stamp_identity(self, result: GroundingResult) -> GroundingResult: """ Set `agent_id` on the result if this instance has an identity and the result doesn't already have one. @@ -167,47 +172,6 @@ def check( self._traces.write_check(concepts, result) return result - def learn_all( - self, - grounding: GroundingResult, - callback: LearningCallback, - concepts: list[str], - min_depth: DepthLevel | None = None, - ) -> GroundingResult: - """ - Iteratively resolve all gaps via the learning loop. - - Processes one gap at a time, re-grounding after each accepted/modified - candidate. Skipped gaps are excluded from subsequent rounds (the user - has acknowledged them). Rejected gaps stop the loop entirely. - Returns the final GroundingResult. - """ - skipped: set[int] = set() - learning_outcomes: list[LearningResult] = [] - with self._traces.suppress(), callback: - while not grounding.grounded and grounding.gaps: - gap_index = next( - (i for i, g in enumerate(grounding.gaps) if i not in skipped), - None, - ) - if gap_index is None: - break - result = self._learning_engine.learn_at(grounding, gap_index, callback) - learning_outcomes.append(result) - self._metrics.update_learning(grounding.gaps[gap_index], result.decision) - if result.decision == CandidateDecision.REJECTED: - break - if result.decision == CandidateDecision.SKIPPED: - skipped.add(gap_index) - continue - self._resolver.invalidate() - grounding = self.check(concepts, min_depth=min_depth) - skipped.clear() - - if learning_outcomes: - self._traces.write_learn(concepts, grounding, learning_outcomes) - return grounding - def check_policy( self, concepts: list[str] | GroundingResult, @@ -245,7 +209,7 @@ def check_policy( try: card_enum = Cardinality(cardinality) except ValueError: - card_enum = None # unknown → fire all policies + card_enum = None # unknown -> fire all policies gate = PolicyGate() violations = gate.evaluate(grounding.trace, card_enum, call_context) diff --git a/src/vre/core/grounding/engine.py b/src/vre/core/grounding/engine.py index d03e774..9cfa026 100644 --- a/src/vre/core/grounding/engine.py +++ b/src/vre/core/grounding/engine.py @@ -17,7 +17,6 @@ from __future__ import annotations import logging -from functools import cache from uuid import UUID from vre.core.graph import PrimitiveRepository @@ -92,32 +91,6 @@ def _identify_roots( transients.append(t) return all_roots, transients - @staticmethod - @cache - def _max_contiguous_from_levels(present: frozenset[DepthLevel]) -> DepthLevel | None: - """ - Highest DepthLevel forming a contiguous chain from D0 in *present*, or None. - - Pure function of the level set — memoized so all primitives sharing a - depth shape share the cached answer. Cache grows with the number of - distinct level-sets the process sees (typically tiny). - """ - result: DepthLevel | None = None - for level in sorted(DepthLevel): - if level not in present: - break - result = level - return result - - @staticmethod - def _contiguous_max_depth(node: Primitive) -> DepthLevel | None: - """ - Return the highest DepthLevel forming a contiguous chain from D0, or None if no depths. - """ - return GroundingEngine._max_contiguous_from_levels( - frozenset(d.level for d in node.depths) - ) - @staticmethod def _partition_edges_by_source_depth( edges: list[EpistemicStep], @@ -136,7 +109,7 @@ def _partition_edges_by_source_depth( src = id_to_prim.get(edge.source_id) if src is None: continue - src_contiguous = GroundingEngine._contiguous_max_depth(src) + src_contiguous = src.contiguous_max_depth if src_contiguous is not None and src_contiguous >= edge.source_depth: visible.append(edge) else: @@ -175,7 +148,7 @@ def _detect_gaps( src = id_to_prim.get(edge.source_id) if src is None or src.id in transient_ids: continue - src_contiguous = GroundingEngine._contiguous_max_depth(src) + src_contiguous = src.contiguous_max_depth existing = depth_gap_map.get(src.id) if existing is None or edge.source_depth > existing[0]: depth_gap_map[src.id] = (edge.source_depth, src_contiguous) @@ -185,7 +158,7 @@ def _detect_gaps( for node in all_nodes: if node.id in transient_ids or node.id not in root_ids: continue - contiguous = GroundingEngine._contiguous_max_depth(node) + contiguous = node.contiguous_max_depth if contiguous is None or contiguous < min_depth: existing = depth_gap_map.get(node.id) if existing is None or min_depth > existing[0]: @@ -208,7 +181,7 @@ def _detect_gaps( tgt_prim = id_to_prim.get(edge.target_id) if tgt_prim is None: continue - tgt_contiguous = GroundingEngine._contiguous_max_depth(tgt_prim) + tgt_contiguous = tgt_prim.contiguous_max_depth if tgt_contiguous is not None and tgt_contiguous >= edge.target_depth: continue pair = (edge.source_id, edge.target_id) @@ -220,7 +193,7 @@ def _detect_gaps( tgt_prim = id_to_prim.get(tgt_id) if src_prim is None or tgt_prim is None: continue - curr = GroundingEngine._contiguous_max_depth(tgt_prim) + curr = tgt_prim.contiguous_max_depth gaps.append(RelationalGap( source=src_prim, target=tgt_prim, diff --git a/src/vre/core/models.py b/src/vre/core/models.py index 0aa4795..69d64da 100644 --- a/src/vre/core/models.py +++ b/src/vre/core/models.py @@ -86,8 +86,6 @@ class PrimitiveMetrics(BaseModel): last_failed: datetime | None = None grounding_count: int = 0 failure_count: int = 0 - learning_count: int = 0 - rejection_count: int = 0 @property def last_exercised(self) -> datetime | None: @@ -160,6 +158,19 @@ class Primitive(BaseModel): provenance: Provenance | None = None metrics: PrimitiveMetrics | None = None + @property + def contiguous_max_depth(self) -> DepthLevel | None: + """ + Highest DepthLevel forming a contiguous chain from D0, or None if no depths. + """ + levels = {d.level for d in self.depths} + result: DepthLevel | None = None + for level in sorted(DepthLevel): + if level not in levels: + break + result = level + return result + def validate_provenance(self) -> None: """ Raise ValueError if provenance is missing on this primitive, any depth, or any relatum. diff --git a/src/vre/guard.py b/src/vre/guard.py index 652bc84..7a4ec53 100644 --- a/src/vre/guard.py +++ b/src/vre/guard.py @@ -14,7 +14,7 @@ def write_file(path: str, text: str) -> str: Behaviour --------- -Each call runs grounding → policy → execution in a single pass: +Each call runs grounding -> policy -> execution in a single pass: 1. VRE grounding is checked (depth derived from graph structure). 2. `on_trace` is fired (if provided) with the `GroundingResult`. @@ -38,7 +38,6 @@ def write_file(path: str, text: str) -> str: if TYPE_CHECKING: from vre import VRE from vre.core.grounding import GroundingResult - from vre.learning.callback import LearningCallback # `concepts` and `cardinality` may be static values or callables that receive # the same (*args, **kwargs) as the decorated function and return the value @@ -56,7 +55,6 @@ def vre_guard( min_depth: DepthLevel | None = None, on_trace: Callable[["GroundingResult"], None] | None = None, on_policy: Callable[[list[PolicyViolation]], bool] | None = None, - on_learn: "LearningCallback | None" = None, ) -> Callable: """ Decorator that gates a function behind VRE grounding and policy checks. @@ -82,12 +80,6 @@ def vre_guard( Optional callback called with the list of PolicyViolation when any violation requires confirmation. Should return True to proceed, False to block. - on_learn: - Optional learning callback invoked when grounding fails. Enters the - auto-learning loop: surfaces gap templates, collects agent/user - responses, persists accepted candidates, and re-grounds iteratively. - When absent, ungrounded results are returned immediately (existing - behaviour). """ def decorator(fn: Callable) -> Callable: """ @@ -98,7 +90,7 @@ def decorator(fn: Callable) -> Callable: @functools.wraps(fn) def wrapped(*args, **kwargs): """ - Run grounding → learning [optional] → policy → execution on each call. + Run grounding -> policy -> execution on each call. """ resolved_concepts = concepts(*args, **kwargs) if callable(concepts) else concepts logger.info("Guard %r: grounding %d concept(s)", tool_name, len(resolved_concepts)) @@ -107,12 +99,6 @@ def wrapped(*args, **kwargs): if on_trace: on_trace(grounding) - if not grounding.grounded and on_learn: - logger.info("Guard %r: entering learning loop (%d gaps)", tool_name, len(grounding.gaps)) - grounding = vre.learn_all(grounding, on_learn, resolved_concepts, min_depth=min_depth) - if on_trace: - on_trace(grounding) - if not grounding.grounded: logger.info("Guard %r: not grounded, returning GroundingResult (%d gaps)", tool_name, len(grounding.gaps)) result = grounding diff --git a/src/vre/learning/__init__.py b/src/vre/learning/__init__.py index ab135b9..271a3f4 100644 --- a/src/vre/learning/__init__.py +++ b/src/vre/learning/__init__.py @@ -1,28 +1,24 @@ # Copyright 2026 Andrew Greene # Licensed under the Apache License, Version 2.0 -from vre.learning.callback import LearningCallback from vre.learning.engine import LearningEngine from vre.learning.models import ( - CandidateDecision, DepthCandidate, ExistenceCandidate, LearningCandidate, - LearningResult, ProposedDepth, ReachabilityCandidate, RelationalCandidate, ) +from vre.learning.templates import template_for_gap __all__ = [ - "CandidateDecision", "DepthCandidate", "ExistenceCandidate", - "LearningCallback", "LearningCandidate", "LearningEngine", - "LearningResult", "ProposedDepth", "ReachabilityCandidate", "RelationalCandidate", + "template_for_gap", ] diff --git a/src/vre/learning/callback.py b/src/vre/learning/callback.py deleted file mode 100644 index d2641d8..0000000 --- a/src/vre/learning/callback.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright 2026 Andrew Greene -# Licensed under the Apache License, Version 2.0 - -""" -LearningCallback — the integrator-facing contract for the learning loop. - -The callback receives a structured candidate template, the full GroundingResult, -and the specific gap being addressed. It returns the (possibly modified) candidate -and a decision. Provenance is derived from the decision by the engine, not set by -the callback. - -Integrators decide: -- Whether to prompt "enter learning mode?" before invoking the callback -- What UI to present for each gap type -- Whether learning is available at all (organizational policy) - -Example:: - - from vre.learning.callback import LearningCallback - from vre.learning.models import LearningCandidate, CandidateDecision - - class InteractiveLearner(LearningCallback): - def __call__( - self, - candidate: LearningCandidate, - grounding: GroundingResult, - gap: KnowledgeGap, - ) -> tuple[LearningCandidate | None, CandidateDecision]: - # Present candidate to user, collect decision - ... -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - -from vre.core.grounding.models import GroundingResult -from vre.core.models import KnowledgeGap -from vre.learning.models import CandidateDecision, LearningCandidate - - -class LearningCallback(ABC): - """ - Abstract base class for learning loop callbacks. - - A callback receives a candidate template, the full GroundingResult for context, - and the specific gap being addressed. - - Lifecycle: - - `__enter__` is called at the start of a learning session (one - `learn_all` invocation). Use it to acquire resources or set state. - - `__exit__` is called when the session ends. Use it to clean up. - - The callback is used as a context manager by `learn_all`:: - - with on_learn: - # engine invokes on_learn(...) for each gap - - Default implementations are no-ops — override only if your callback - needs session lifecycle management. - - Returns: - - (candidate, ACCEPTED) — persist as proposed, provenance: learned - - (modified_candidate, MODIFIED) — persist modified version, provenance: conversational - - (None, SKIPPED) — intentionally dismissed (e.g. edge absence is enforcement), loop continues - - (None, REJECTED) — discard, stops the learning loop entirely - """ - - @abstractmethod - def __call__( - self, - candidate: LearningCandidate, - grounding: GroundingResult, - gap: KnowledgeGap, - ) -> tuple[LearningCandidate | None, CandidateDecision]: - ... - - def __enter__(self) -> LearningCallback: - return self - - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - pass diff --git a/src/vre/learning/engine.py b/src/vre/learning/engine.py index b41483a..c2079b3 100644 --- a/src/vre/learning/engine.py +++ b/src/vre/learning/engine.py @@ -2,10 +2,11 @@ # Licensed under the Apache License, Version 2.0 """ -LearningEngine — orchestrates the auto-learning loop. +LearningEngine — validates and persists candidate fills for knowledge gaps. -Processes one gap at a time: template creation -> callback invocation -> -validation -> persistence -> re-grounding. +Integrators identify gaps via VRE.check(), create templates via +template_for_gap(), fill them however they choose, and pass the filled +candidate here for validation and persistence. """ import logging @@ -14,10 +15,8 @@ from vre.core.errors import CandidateValidationError, CyclicRelationshipError from vre.core.graph import PrimitiveRepository -from vre.core.grounding.models import GroundingResult from vre.core.models import ( Depth, - DepthGap, DepthLevel, ExistenceGap, KnowledgeGap, @@ -27,37 +26,24 @@ ReachabilityGap, Relatum, RelationalGap, + DepthGap, ) -from vre.learning.callback import LearningCallback from vre.learning.models import ( - CandidateDecision, DepthCandidate, ExistenceCandidate, LearningCandidate, - LearningResult, ProposedDepth, ReachabilityCandidate, RelationalCandidate, ) -from vre.learning.templates import template_for_gap -def _make_provenance(decision: CandidateDecision) -> Provenance: +def _make_provenance(source: ProvenanceSource) -> Provenance: """ - Derive provenance from the candidate decision. + Create a timestamped provenance record with the given source. """ - source = ( - ProvenanceSource.LEARNED - if decision == CandidateDecision.ACCEPTED - else ProvenanceSource.CONVERSATIONAL - ) - detail = ( - "auto-learning: accepted as proposed" - if decision == CandidateDecision.ACCEPTED - else "auto-learning: modified by user" - ) now = datetime.now(timezone.utc) - return Provenance(source=source, created_at=now, updated_at=now, detail=detail) + return Provenance(source=source, created_at=now, updated_at=now) def _to_depth(proposed: ProposedDepth, provenance: Provenance) -> Depth: @@ -67,43 +53,68 @@ def _to_depth(proposed: ProposedDepth, provenance: Provenance) -> Depth: return Depth(level=proposed.level, properties=proposed.properties, provenance=provenance) -def _resolve_name_to_id(name: str, grounding: GroundingResult) -> UUID: - """ - Resolve a primitive name to its UUID from the grounding trace. - """ - for p in grounding.get_primitives(): - if p.name.lower() == name.lower(): - return p.id - raise CandidateValidationError(f"Cannot resolve '{name}' to a primitive ID from the grounding trace") - - logger = logging.getLogger(__name__) class LearningEngine: """ - Processes knowledge gaps via the auto-learning loop. + Validates and persists candidate fills for knowledge gaps. - The engine: - 1. Creates a template from the gap - 2. Invokes the callback (agent fills template, user reviews) - 3. Validates and persists accepted/modified candidates using gap context - 4. Returns the result with the decision + The engine does not orchestrate a learning loop — that is the + integrator's responsibility. It provides a single entry point, + `learn_gap`, which validates a filled candidate against its gap + and persists it to the graph. """ def __init__(self, repository: PrimitiveRepository) -> None: self._repo = repository + def reachability_prerequisites( + self, + gap: ReachabilityGap, + candidate: ReachabilityCandidate, + ) -> list[DepthGap]: + """ + Return DepthGaps that must be filled before this edge can be placed. + + Checks that both source and target have the required depth levels. + Returns an empty list if no prerequisites are missing. + """ + candidate.validate_for_gap(gap) + + prerequisites: list[DepthGap] = [] + for name, required_level in [ + (candidate.source_name, candidate.source_depth_level), + (candidate.target_name, candidate.target_depth_level), + ]: + primitive = self._repo.find_by_name(name) + if primitive is None: + raise CandidateValidationError(f"Cannot resolve '{name}' to a primitive ID") + existing_levels = {d.level for d in primitive.depths} + if required_level not in existing_levels: + prerequisites.append(DepthGap( + primitive=primitive, + required_depth=required_level, + current_depth=primitive.contiguous_max_depth, + )) + + return prerequisites + + def _resolve_name_to_id(self, name: str) -> UUID: + """ + Resolve a primitive name to its UUID from the repository. + """ + primitive = self._repo.find_by_name(name) + if primitive is None: + raise CandidateValidationError(f"Cannot resolve '{name}' to a primitive ID") + return primitive.id + def _persist_existence( self, gap: ExistenceGap, candidate: ExistenceCandidate, provenance: Provenance, ) -> None: """ Persist a new primitive with D0 (auto-generated) and agent-provided D1. """ - if candidate.d1 is None: - logger.warning("ExistenceCandidate %r missing D1 (identity)", candidate.name) - raise CandidateValidationError(f"ExistenceCandidate '{candidate.name}' is missing D1 (identity)") - d0 = Depth( level=DepthLevel.EXISTENCE, properties={"exists": True}, @@ -125,7 +136,7 @@ def _merge_depths( """ Merge proposed depth levels into a primitive and persist. - Converts ProposedDepth → Depth, replaces existing depth levels when the + Converts ProposedDepth -> Depth, replaces existing depth levels when the level already exists, appends otherwise. Sorts and saves. """ logger.debug( @@ -139,9 +150,6 @@ def _merge_depths( depth = _to_depth(proposed, provenance) touched_levels.add(depth.level) if depth.level in existing_levels: - # Carry forward relata from the old depth — edges and properties - # are independent concerns; replacing descriptive knowledge should - # not silently drop validated relationships. old = next(d for d in primitive.depths if d.level == depth.level) depth.relata = old.relata primitive.depths = [ @@ -151,7 +159,6 @@ def _merge_depths( primitive.depths.append(depth) existing_levels.add(depth.level) - # Stamp provenance on carried-forward relata that lack it for depth in primitive.depths: if depth.level not in touched_levels: continue @@ -168,10 +175,6 @@ def _persist_depth( """ Merge new depth levels into an existing primitive and persist. """ - if not candidate.new_depths: - logger.warning("DepthCandidate for %r has no new depths", gap.primitive.name) - raise CandidateValidationError(f"DepthCandidate for '{gap.primitive.name}' has no new depths") - existing = self._repo.find_by_id(gap.primitive.id) if existing is None: raise CandidateValidationError(f"Primitive '{gap.primitive.name}' ({gap.primitive.id}) not found") @@ -188,10 +191,6 @@ def _persist_relational( """ Merge new depth levels into the target primitive and persist. """ - if not candidate.new_depths: - logger.warning("RelationalCandidate for %r has no new depths", gap.target.name) - raise CandidateValidationError(f"RelationalCandidate for '{gap.target.name}' has no new depths") - target = self._repo.find_by_id(gap.target.id) if target is None: raise CandidateValidationError(f"Target '{gap.target.name}' ({gap.target.id}) not found") @@ -199,151 +198,81 @@ def _persist_relational( self._merge_depths(target, candidate.new_depths, provenance) logger.debug("Merged relational depths into target %r", target.name) - def _learn_missing_depths( - self, - primitive: Primitive, - required_level: DepthLevel, - grounding: GroundingResult, - callback: LearningCallback, - ) -> CandidateDecision | None: - """ - If the primitive lacks the required depth level, synthesize a DepthGap - and invoke the callback to learn the missing depths. Returns the - decision if learning was needed, or None if the depth already exists. - """ - existing_levels = {d.level for d in primitive.depths} - result: CandidateDecision | None = None - if required_level in existing_levels: - logger.debug("Depth D%d already present on %r, skipping sub-learning", int(required_level), primitive.name) - else: - current_depth = max(existing_levels, key=lambda lv: lv.value) if existing_levels else None - logger.debug( - "Synthesized DepthGap for %r: requires D%d, current=%s", - primitive.name, int(required_level), - ("D" + str(int(current_depth))) if current_depth is not None else "None", - ) - gap = DepthGap( - primitive=primitive, - required_depth=required_level, - current_depth=current_depth, - ) - template = template_for_gap(gap) - filled, decision = callback(template, grounding, gap) - - if decision in (CandidateDecision.SKIPPED, CandidateDecision.REJECTED): - result = decision - elif filled is None: - logger.warning("Callback returned None for synthesized depth gap on %r, treating as REJECTED", primitive.name) - result = CandidateDecision.REJECTED - else: - provenance = _make_provenance(decision) - self._persist_depth(gap, filled, provenance) - result = decision - return result - def _persist_reachability( self, gap: ReachabilityGap, candidate: ReachabilityCandidate, - grounding: GroundingResult, - callback: LearningCallback, provenance: Provenance, - ) -> CandidateDecision: + ) -> None: """ - Two-phase edge placement: - 1. Learn any missing depths on source and target via the callback - 2. Place the edge once both sides have the required depths - - If depth learning is rejected or skipped, edge placement is abandoned - and the decision propagates back as the outcome. + Resolve names, check depth requirements, and place an edge. """ - if candidate.target_name is None or candidate.relation_type is None: - logger.warning( - "ReachabilityCandidate for %r missing target_name or relation_type", - gap.primitive.name, - ) - raise CandidateValidationError( - f"ReachabilityCandidate for '{gap.primitive.name}' is missing " - f"target_name or relation_type" - ) - if candidate.source_depth_level is None or candidate.target_depth_level is None: - raise CandidateValidationError( - f"ReachabilityCandidate for '{gap.primitive.name}' is missing " - f"source_depth_level or target_depth_level" - ) + source_id = self._resolve_name_to_id(candidate.source_name) + target_id = self._resolve_name_to_id(candidate.target_name) - target_id = _resolve_name_to_id(candidate.target_name, grounding) - - source = self._repo.find_by_id(gap.primitive.id) + source = self._repo.find_by_id(source_id) if source is None: - raise CandidateValidationError(f"Source '{gap.primitive.name}' ({gap.primitive.id}) not found") + raise CandidateValidationError(f"Source '{candidate.source_name}' ({source_id}) not found") target = self._repo.find_by_id(target_id) if target is None: raise CandidateValidationError(f"Target '{candidate.target_name}' ({target_id}) not found") - # Phase 1: learn missing depths on source, then target. Either rejection - # or skip overrides the outcome and short-circuits phase 2. + source_levels = {d.level for d in source.depths} + if candidate.source_depth_level not in source_levels: + raise CandidateValidationError( + f"Source '{source.name}' requires D{candidate.source_depth_level.value} " + f"({candidate.source_depth_level.name}) but only has " + f"{sorted('D' + str(int(lv)) for lv in source_levels)}. " + f"Fill the DepthGap first." + ) + + target_levels = {d.level for d in target.depths} + if candidate.target_depth_level not in target_levels: + raise CandidateValidationError( + f"Target '{target.name}' requires D{candidate.target_depth_level.value} " + f"({candidate.target_depth_level.name}) but only has " + f"{sorted('D' + str(int(lv)) for lv in target_levels)}. " + f"Fill the DepthGap first." + ) + logger.debug( - "Reachability two-phase: learning missing depths on source=%r, target=%r", - source.name, target.name, + "Placing %s edge from %r (D%d) to %r (D%d)", + candidate.relation_type.value, source.name, int(candidate.source_depth_level), + candidate.target_name, int(candidate.target_depth_level), ) - outcome: CandidateDecision = CandidateDecision.ACCEPTED - src_decision = self._learn_missing_depths(source, candidate.source_depth_level, grounding, callback) - if src_decision in (CandidateDecision.REJECTED, CandidateDecision.SKIPPED): - outcome = src_decision - else: - if src_decision is not None: - source = self._repo.find_by_id(source.id) - tgt_decision = self._learn_missing_depths(target, candidate.target_depth_level, grounding, callback) - if tgt_decision in (CandidateDecision.REJECTED, CandidateDecision.SKIPPED): - outcome = tgt_decision - - if outcome == CandidateDecision.ACCEPTED: - # Phase 2: place the edge - logger.debug( - "Reachability two-phase: placing %s edge from %r (D%d) to %r (D%d)", - candidate.relation_type.value, source.name, int(candidate.source_depth_level), - candidate.target_name, int(candidate.target_depth_level), - ) - depth_obj = next(d for d in source.depths if d.level == candidate.source_depth_level) - new_relatum = Relatum( - relation_type=candidate.relation_type, - target_id=target_id, - target_depth=candidate.target_depth_level, - provenance=provenance, + depth_obj = next(d for d in source.depths if d.level == candidate.source_depth_level) + new_relatum = Relatum( + relation_type=candidate.relation_type, + target_id=target_id, + target_depth=candidate.target_depth_level, + provenance=provenance, + ) + depth_obj.relata.append(new_relatum) + source.depths.sort(key=lambda d: int(d.level)) + try: + self._repo.save_primitive(source) + except CyclicRelationshipError: + logger.exception( + "Cyclic relationship error placing %s edge from %r (%s) to %r (%s)", + candidate.relation_type.value, + source.name, + source.id, + target.name, + target.id, ) - depth_obj.relata.append(new_relatum) - source.depths.sort(key=lambda d: int(d.level)) - try: - self._repo.save_primitive(source) - except CyclicRelationshipError: - logger.exception( - "Cyclic relationship error placing %s edge from %r (%s) to %r (%s)", - candidate.relation_type.value, - source.name, - source.id, - target.name, - target.id, - ) - depth_obj.relata.remove(new_relatum) - raise - - return outcome + depth_obj.relata.remove(new_relatum) + raise def _persist( self, gap: KnowledgeGap, candidate: LearningCandidate, - grounding: GroundingResult, - callback: LearningCallback, provenance: Provenance, - ) -> CandidateDecision | None: + ) -> None: """ - Validate and persist a filled candidate to the graph. - Returns a decision override for reachability (two-phase), or None. + Persist a validated candidate to the graph. """ - override: CandidateDecision | None = None match (gap, candidate): case (ExistenceGap(), ExistenceCandidate()): self._persist_existence(gap, candidate, provenance) @@ -352,56 +281,20 @@ def _persist( case (RelationalGap(), RelationalCandidate()): self._persist_relational(gap, candidate, provenance) case (ReachabilityGap(), ReachabilityCandidate()): - override = self._persist_reachability(gap, candidate, grounding, callback, provenance) - return override + self._persist_reachability(gap, candidate, provenance) - def learn_at( + def learn_gap( self, - grounding: GroundingResult, - gap_index: int, - callback: LearningCallback, - ) -> LearningResult: - """ - Process the gap at the given index in the grounding result. + gap: KnowledgeGap, + candidate: LearningCandidate, + source: ProvenanceSource = ProvenanceSource.LEARNED, + ) -> None: """ - if not grounding.gaps: - raise CandidateValidationError("No gaps to learn from") - if gap_index < 0 or gap_index >= len(grounding.gaps): - raise CandidateValidationError(f"Gap index {gap_index} out of range (0..{len(grounding.gaps) - 1})") - - gap = grounding.gaps[gap_index] - logger.info("Learning at gap_index=%d, gap_kind=%s", gap_index, gap.kind) - candidate = template_for_gap(gap) - filled, decision = callback(candidate, grounding, gap) - - result: LearningResult - if decision == CandidateDecision.SKIPPED: - logger.info("Gap %d skipped by callback", gap_index) - result = LearningResult(decision=CandidateDecision.SKIPPED, candidate=candidate) - elif decision == CandidateDecision.REJECTED: - logger.info("Gap %d rejected by callback", gap_index) - result = LearningResult(decision=CandidateDecision.REJECTED, candidate=candidate) - elif filled is None: - logger.warning( - "Callback for gap %d returned no candidate (decision=%s); treating as REJECTED", - gap_index, - decision.value, - ) - result = LearningResult(decision=CandidateDecision.REJECTED, candidate=candidate) - else: - logger.debug( - "Persisting %s candidate for gap %d (decision=%s)", - type(filled).__name__, gap_index, decision.value, - ) - provenance = _make_provenance(decision) - override = self._persist(gap, filled, grounding, callback, provenance) - - # Reachability two-phase can override the decision if depth - # learning was rejected or skipped - if override in (CandidateDecision.REJECTED, CandidateDecision.SKIPPED): - logger.info("Reachability two-phase override: %s", override.value) - result = LearningResult(decision=override, candidate=filled) - else: - result = LearningResult(decision=decision, candidate=filled) - return result + Validate and persist a filled candidate for the given gap. + Raises CandidateValidationError if the candidate is invalid. + """ + logger.info("Learning gap: %s", gap.kind) + candidate.validate_for_gap(gap) + provenance = _make_provenance(source) + self._persist(gap, candidate, provenance) diff --git a/src/vre/learning/models.py b/src/vre/learning/models.py index b36db90..25b3f9a 100644 --- a/src/vre/learning/models.py +++ b/src/vre/learning/models.py @@ -2,38 +2,21 @@ # Licensed under the Apache License, Version 2.0 """ -Candidate models for the VRE auto-learning loop. +Candidate models for VRE learning. -Candidates carry only what's *new* — the agent's proposed knowledge. All -context (primitive IDs, existing depths, required depths) lives on the gap -itself, which the engine already has access to. +Candidates carry only what's *new* — the integrator's proposed knowledge. +All context (primitive IDs, existing depths, required depths) lives on +the gap itself, which the engine already has access to. This keeps the models lightweight and compatible with LLM structured output. """ -from enum import Enum from typing import Annotated, Any, Literal from pydantic import BaseModel, Field -from vre.core.models import DepthLevel, RelationType - - -class CandidateDecision(str, Enum): - """ - Outcome of a learning candidate review. - - ACCEPTED — agent proposal persisted as-is (provenance: learned). - MODIFIED — user refined the proposal before persistence (provenance: conversational). - REJECTED — candidate discarded, nothing persisted. Stops the learning loop entirely. - SKIPPED — candidate intentionally dismissed (e.g. edge absence is deliberate - enforcement). The loop continues to the next gap. - """ - - ACCEPTED = "accepted" - MODIFIED = "modified" - REJECTED = "rejected" - SKIPPED = "skipped" +from vre.core.errors import CandidateValidationError +from vre.core.models import DepthLevel, KnowledgeGap, RelationType class ProposedDepth(BaseModel): @@ -67,6 +50,12 @@ class ExistenceCandidate(BaseModel): name: str d1: ProposedDepth | None = None + def validate_for_gap(self, gap: KnowledgeGap) -> None: + if self.d1 is None: + raise CandidateValidationError( + f"ExistenceCandidate '{self.name}' is missing D1 (identity)" + ) + class DepthCandidate(BaseModel): """ @@ -79,6 +68,12 @@ class DepthCandidate(BaseModel): kind: Literal["DEPTH"] = "DEPTH" new_depths: list[ProposedDepth] = Field(default_factory=list) + def validate_for_gap(self, gap: KnowledgeGap) -> None: + if not self.new_depths: + raise CandidateValidationError( + f"DepthCandidate for '{gap.primitive.name}' has no new depths" + ) + class RelationalCandidate(BaseModel): """ @@ -91,26 +86,37 @@ class RelationalCandidate(BaseModel): kind: Literal["RELATIONAL"] = "RELATIONAL" new_depths: list[ProposedDepth] = Field(default_factory=list) + def validate_for_gap(self, gap: KnowledgeGap) -> None: + if not self.new_depths: + raise CandidateValidationError( + f"RelationalCandidate for '{gap.target.name}' has no new depths" + ) + class ReachabilityCandidate(BaseModel): """ Proposal for a ReachabilityGap — concept not connected to other concepts. - Focused solely on edge placement. The agent proposes which target to - connect to, the relation type, and the depth levels for the edge. - The engine resolves target_name to an ID from the grounding trace and - scaffolds any missing depths as stubs. If the scaffolded depths lack - required knowledge, re-grounding will surface them as depth or - relational gaps for the loop to handle naturally. + Focused solely on edge placement. The integrator proposes both the + source and target of the edge, the relation type, and the depth + levels. At least one of ``source_name`` or ``target_name`` must + match the gap's primitive — the edge must fix *this* disconnection. + The direction is up to the integrator. - If the absence is intentional enforcement, the user skips instead of - filling this in. + Source and target must already have the required depth levels before + edge placement; if they don't, ``learn_gap`` raises + ``CandidateValidationError`` telling the integrator which DepthGaps + to fill first. """ kind: Literal["REACHABILITY"] = "REACHABILITY" + source_name: str | None = Field( + default=None, + description="Name of the source concept (edge originates here).", + ) target_name: str | None = Field( default=None, - description="Name of the target concept to connect to.", + description="Name of the target concept (edge points here).", ) relation_type: RelationType | None = Field( default=None, @@ -118,24 +124,42 @@ class ReachabilityCandidate(BaseModel): ) source_depth_level: DepthLevel | None = Field( default=None, - description="Depth level on the source where the edge is placed. Determines when the agent can reason about this relationship.", + description="Depth level on the source where the edge is placed.", ) target_depth_level: DepthLevel | None = Field( default=None, description="Minimum depth required on the target for the edge to resolve.", ) + def validate_for_gap(self, gap: KnowledgeGap) -> None: + if self.source_name is None or self.target_name is None: + raise CandidateValidationError( + f"ReachabilityCandidate for '{gap.primitive.name}' is missing " + f"source_name or target_name" + ) + if self.relation_type is None: + raise CandidateValidationError( + f"ReachabilityCandidate for '{gap.primitive.name}' is missing " + f"relation_type" + ) + if self.source_depth_level is None or self.target_depth_level is None: + raise CandidateValidationError( + f"ReachabilityCandidate for '{gap.primitive.name}' is missing " + f"source_depth_level or target_depth_level" + ) + gap_name = gap.primitive.name.lower() + if ( + self.source_name.lower() != gap_name + and self.target_name.lower() != gap_name + ): + raise CandidateValidationError( + f"ReachabilityCandidate must reference the gapped primitive " + f"'{gap.primitive.name}' as either source or target, " + f"got source='{self.source_name}', target='{self.target_name}'" + ) + LearningCandidate = Annotated[ ExistenceCandidate | DepthCandidate | RelationalCandidate | ReachabilityCandidate, Field(discriminator="kind"), ] - - -class LearningResult(BaseModel): - """ - Outcome of a single learning round. - """ - - decision: CandidateDecision - candidate: LearningCandidate diff --git a/src/vre/metrics.py b/src/vre/metrics.py index f0bcb0e..d23924b 100644 --- a/src/vre/metrics.py +++ b/src/vre/metrics.py @@ -3,8 +3,8 @@ """ MetricsManager — coordinates per-primitive metric updates after grounding -and learning operations. Updates are best-effort; failures are logged and -never raise to the caller. +operations. Updates are best-effort; failures are logged and never raise +to the caller. """ import logging @@ -14,15 +14,9 @@ from vre.core.graph import PrimitiveRepository from vre.core.grounding.models import GroundingResult from vre.core.models import ( - DepthGap, - ExistenceGap, - KnowledgeGap, PrimitiveMetrics, - ReachabilityGap, - RelationalGap, gap_primitive_ids, ) -from vre.learning.models import CandidateDecision logger = logging.getLogger(__name__) @@ -30,7 +24,7 @@ class MetricsManager: """ - Internal coordinator for primitive-level grounding and learning metrics. + Internal coordinator for primitive-level grounding metrics. """ def __init__(self, repository: PrimitiveRepository) -> None: @@ -77,42 +71,3 @@ def update_grounding(self, result: GroundingResult) -> None: self._repo.batch_update_metrics(updates) except Exception: logger.warning("Failed to batch-update metrics for %d primitives", len(updates), exc_info=True) - - def update_learning( - self, - gap: KnowledgeGap, - decision: CandidateDecision, - ) -> None: - """ - Update learning metrics on the primitive targeted by a gap. - - Increments `learning_count` for accepted/modified decisions and - `rejection_count` for rejected decisions. SKIPPED decisions are - ignored. Looks up the primitive by ID first, falling back to name - for ExistenceGaps where the gap carries a transient ID. - """ - prim_id: UUID | None = None - prim_name: str | None = None - if decision != CandidateDecision.SKIPPED: - match gap: - case RelationalGap(): - prim_id, prim_name = gap.target.id, gap.target.name - case DepthGap() | ExistenceGap() | ReachabilityGap(): - prim_id, prim_name = gap.primitive.id, gap.primitive.name - - found = None - if prim_id is not None: - found = self._repo.find_by_id(prim_id) - if found is None and prim_name is not None: - found = self._repo.find_by_name(prim_name) - - if found is not None: - metrics = found.metrics or PrimitiveMetrics() - if decision in (CandidateDecision.ACCEPTED, CandidateDecision.MODIFIED): - metrics.learning_count += 1 - elif decision == CandidateDecision.REJECTED: - metrics.rejection_count += 1 - try: - self._repo.update_metrics(found.id, metrics) - except Exception: - logger.warning("Failed to update learning metrics for %r", prim_name, exc_info=True) diff --git a/src/vre/tracing.py b/src/vre/tracing.py index a6a4efc..3578ffc 100644 --- a/src/vre/tracing.py +++ b/src/vre/tracing.py @@ -5,7 +5,7 @@ Trace persistence — serializes grounding results to daily JSONL files. Enabled by default on every VRE instance. Traces are written to -``~/.vre/traces/YYYY-MM-DD.jsonl``. Disable with ``persist_traces=False``. +`~/.vre/traces/YYYY-MM-DD.jsonl`. Disable with `persist_traces=False`. """ import logging @@ -19,7 +19,6 @@ from pydantic import BaseModel, Field from vre.core.grounding.models import GroundingResult -from vre.learning.models import LearningResult logger = logging.getLogger(__name__) @@ -27,37 +26,30 @@ class TraceEntry(BaseModel): """ - A single JSONL line representing one grounding or learning operation. + A single JSONL line representing one grounding operation. """ timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - operation: Literal["check", "learn"] + operation: Literal["check"] concepts: list[str] resolved: list[str] grounded: bool gaps: list[dict[str, Any]] = Field(default_factory=list) steps: list[dict[str, Any]] = Field(default_factory=list) agent_id: str | None = None - learning_outcomes: list[dict[str, Any]] | None = None def build_trace_entry( - operation: Literal["check", "learn"], + operation: Literal["check"], concepts: list[str], result: GroundingResult, - learning_outcomes: list[LearningResult] | None = None, ) -> TraceEntry: """ - Construct a `TraceEntry` from a `GroundingResult` and optional learning outcomes. + Construct a `TraceEntry` from a `GroundingResult`. """ gaps = [gap.model_dump(mode="json") for gap in result.gaps] - steps = [step.model_dump(mode="json") for step in result.get_pathway_steps()] - serialized_outcomes: list[dict[str, Any]] | None = None - if learning_outcomes is not None: - serialized_outcomes = [lr.model_dump(mode="json") for lr in learning_outcomes] - return TraceEntry( operation=operation, concepts=concepts, @@ -66,7 +58,6 @@ def build_trace_entry( gaps=gaps, steps=steps, agent_id=str(result.agent_id) if result.agent_id is not None else None, - learning_outcomes=serialized_outcomes, ) @@ -78,7 +69,7 @@ class TraceWriter: Appends `TraceEntry` objects to daily JSONL files. Files are named `YYYY-MM-DD.jsonl` under the trace directory - (defaults to ``~/.vre/traces/``). Each line is independently valid JSON. + (defaults to `~/.vre/traces/`). Each line is independently valid JSON. """ def __init__(self, trace_dir: Path | None = None) -> None: @@ -99,9 +90,7 @@ def write(self, entry: TraceEntry) -> None: class TraceManager: """ - Internal coordinator owning the TraceWriter and the in-flight suppression - state used by `learn_all` to skip per-iteration `check()` traces in favor - of a single summary trace at the end of the loop. + Internal coordinator owning the TraceWriter. All write paths are best-effort: persistence failures are logged and never raise to the caller. @@ -140,20 +129,3 @@ def write_check(self, concepts: list[str], result: GroundingResult) -> None: """ if self._writer is not None and not self._suppressed: self._safe_write(build_trace_entry("check", concepts, result), label="check") - - def write_learn( - self, - concepts: list[str], - result: GroundingResult, - outcomes: list[LearningResult], - ) -> None: - """ - Persist a 'learn' trace entry summarizing a learning loop. No-op when - no writer is configured. Not affected by `suppress()` — learning - summaries are the reason `learn_all` suppresses its inner check writes. - """ - if self._writer is not None: - self._safe_write( - build_trace_entry("learn", concepts, result, outcomes), - label="learn_all", - ) diff --git a/tests/vre/test_guard.py b/tests/vre/test_guard.py index 85a6a81..0a3d819 100644 --- a/tests/vre/test_guard.py +++ b/tests/vre/test_guard.py @@ -9,8 +9,6 @@ from vre.core.policy import PolicyAction, PolicyResult from vre.core.policy.models import PolicyViolation, Policy from vre.guard import vre_guard -from vre.learning.callback import LearningCallback -from vre.learning.models import CandidateDecision # ── helpers ────────────────────────────────────────────────────────────────── @@ -397,75 +395,6 @@ def my_fn(): mock_vre.check.assert_called_once_with(["file"], min_depth=None) -# ── on_learn path ──────────────────────────────────────────────────────────── - -class _StubLearner(LearningCallback): - """Minimal LearningCallback for guard tests.""" - def __call__(self, candidate, grounding, gap): - return None, CandidateDecision.REJECTED - - -def test_vre_guard_on_learn_invokes_learn_all_when_not_grounded(): - """When grounding fails and on_learn is provided, learn_all is called.""" - from vre.guard import vre_guard - - grounded_result = _grounding(grounded=True) - mock_vre = _mock_vre(_grounding(grounded=False, gaps=[MagicMock()])) - mock_vre.learn_all.return_value = grounded_result - mock_vre.check_policy.return_value = PolicyResult(action=PolicyAction.PASS) - - learner = _StubLearner() - - @vre_guard(mock_vre, concepts=["file"], on_learn=learner) - def my_fn(): - return "executed" - - result = my_fn() - assert result == "executed" - mock_vre.learn_all.assert_called_once() - - -def test_vre_guard_on_learn_fires_on_trace_after_learning(): - """on_trace fires twice: once after initial grounding, once after learning.""" - from vre.guard import vre_guard - - traces = [] - grounded_result = _grounding(grounded=True) - mock_vre = _mock_vre(_grounding(grounded=False, gaps=[MagicMock()])) - mock_vre.learn_all.return_value = grounded_result - mock_vre.check_policy.return_value = PolicyResult(action=PolicyAction.PASS) - - learner = _StubLearner() - - @vre_guard(mock_vre, concepts=["file"], on_trace=traces.append, on_learn=learner) - def my_fn(): - return "executed" - - my_fn() - assert len(traces) == 2 - assert traces[0].grounded is False - assert traces[1].grounded is True - - -def test_vre_guard_on_learn_returns_grounding_when_still_not_grounded(): - """When learn_all doesn't fully resolve gaps, returns the GroundingResult.""" - from vre.guard import vre_guard - - still_ungrounded = _grounding(grounded=False, gaps=[MagicMock()]) - mock_vre = _mock_vre(_grounding(grounded=False, gaps=[MagicMock()])) - mock_vre.learn_all.return_value = still_ungrounded - - learner = _StubLearner() - - @vre_guard(mock_vre, concepts=["file"], on_learn=learner) - def my_fn(): - return "executed" - - result = my_fn() - assert isinstance(result, GroundingResult) - assert result.grounded is False - - def test_vre_guard_on_policy_decline_returns_block(): """When on_policy returns False (inside check_policy), returns PolicyResult(BLOCK).""" from vre.guard import vre_guard diff --git a/tests/vre/test_learning.py b/tests/vre/test_learning.py index 25d5c3e..1b3beac 100644 --- a/tests/vre/test_learning.py +++ b/tests/vre/test_learning.py @@ -10,16 +10,11 @@ import pytest -from vre.core.grounding.models import GroundingResult from vre.core.errors import CandidateValidationError, CyclicRelationshipError from vre.core.models import ( Depth, DepthGap, DepthLevel, - EpistemicQuery, - EpistemicResponse, - EpistemicResult, - EpistemicStep, ExistenceGap, Primitive, Provenance, @@ -30,10 +25,8 @@ RelationType, TRANSITIVE_RELATION_TYPES, ) -from vre.learning.callback import LearningCallback from vre.learning.engine import LearningEngine, _make_provenance from vre.learning.models import ( - CandidateDecision, DepthCandidate, ExistenceCandidate, ProposedDepth, @@ -75,24 +68,6 @@ def _depth( ) -def _grounding_result( - grounded: bool, - gaps: list | None = None, - primitives: list[Primitive] | None = None, -) -> GroundingResult: - prims = primitives or [] - trace = EpistemicResponse( - query=EpistemicQuery(concept_ids=[]), - result=EpistemicResult(primitives=prims, gaps=[], pathway=[]), - ) if prims else None - return GroundingResult( - grounded=grounded, - resolved=[], - gaps=gaps or [], - trace=trace, - ) - - class StubRepository: """ In-memory repository for learning engine tests. @@ -100,13 +75,18 @@ class StubRepository: def __init__(self, primitives: list[Primitive] | None = None) -> None: self._by_id: dict[UUID, Primitive] = {} + self._by_name: dict[str, Primitive] = {} for p in primitives or []: self._by_id[p.id] = p + self._by_name[p.name.lower()] = p self.saved: list[Primitive] = [] def find_by_id(self, id: UUID) -> Primitive | None: return self._by_id.get(id) + def find_by_name(self, name: str) -> Primitive | None: + return self._by_name.get(name.lower()) + def save_primitive(self, primitive: Primitive) -> None: for depth in primitive.depths: for relatum in depth.relata: @@ -137,6 +117,7 @@ def save_primitive(self, primitive: Primitive) -> None: visited.add(r.target_id) queue.append(r.target_id) self._by_id[primitive.id] = primitive + self._by_name[primitive.name.lower()] = primitive self.saved.append(primitive) @@ -178,6 +159,7 @@ def test_reachability_is_empty(self): gap = ReachabilityGap(primitive=prim) template = template_for_gap(gap) assert isinstance(template, ReachabilityCandidate) + assert template.source_name is None assert template.target_name is None assert template.relation_type is None assert template.source_depth_level is None @@ -190,15 +172,13 @@ def test_reachability_is_empty(self): # --------------------------------------------------------------------------- class TestMakeProvenance: - def test_accepted_is_learned(self): - prov = _make_provenance(CandidateDecision.ACCEPTED) + def test_learned_source(self): + prov = _make_provenance(ProvenanceSource.LEARNED) assert prov.source == ProvenanceSource.LEARNED - assert "accepted" in prov.detail - def test_modified_is_conversational(self): - prov = _make_provenance(CandidateDecision.MODIFIED) + def test_conversational_source(self): + prov = _make_provenance(ProvenanceSource.CONVERSATIONAL) assert prov.source == ProvenanceSource.CONVERSATIONAL - assert "modified" in prov.detail # --------------------------------------------------------------------------- @@ -210,18 +190,13 @@ def test_saves_primitive_with_d0_and_d1(self): repo = StubRepository() engine = LearningEngine(repo) gap = ExistenceGap(primitive=_primitive("Copy")) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = ExistenceCandidate( name="Copy", d1=ProposedDepth(level=DepthLevel.IDENTITY, properties={"description": "Duplicates content"}), ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - result = engine.learn_at(grounding, 0, callback) - assert result.decision == CandidateDecision.ACCEPTED + engine.learn_gap(gap, filled) assert len(repo.saved) == 1 saved = repo.saved[0] assert saved.name == "Copy" @@ -234,32 +209,23 @@ def test_rejects_missing_d1(self): repo = StubRepository() engine = LearningEngine(repo) gap = ExistenceGap(primitive=_primitive("Copy")) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = ExistenceCandidate(name="Copy", d1=None) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - with pytest.raises(CandidateValidationError, match="missing D1"): - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) - def test_modified_gets_conversational_provenance(self): + def test_conversational_provenance(self): repo = StubRepository() engine = LearningEngine(repo) gap = ExistenceGap(primitive=_primitive("Copy")) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = ExistenceCandidate( name="Copy", d1=ProposedDepth(level=DepthLevel.IDENTITY, properties={"description": "Duplicates"}), ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.MODIFIED - - result = engine.learn_at(grounding, 0, callback) - assert result.decision == CandidateDecision.MODIFIED + engine.learn_gap(gap, filled, source=ProvenanceSource.CONVERSATIONAL) saved = repo.saved[0] assert saved.provenance.source == ProvenanceSource.CONVERSATIONAL @@ -278,16 +244,12 @@ def test_merges_new_depth_into_existing(self): required_depth=DepthLevel.CAPABILITIES, current_depth=DepthLevel.IDENTITY, ) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = DepthCandidate( new_depths=[ProposedDepth(level=DepthLevel.CAPABILITIES, properties={"can_read": "true"})], ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) saved = repo.saved[0] levels = [d.level for d in saved.depths] assert DepthLevel.CAPABILITIES in levels @@ -298,15 +260,11 @@ def test_rejects_empty_new_depths(self): repo = StubRepository([prim]) engine = LearningEngine(repo) gap = DepthGap(primitive=prim, required_depth=DepthLevel.CAPABILITIES, current_depth=DepthLevel.EXISTENCE) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = DepthCandidate(new_depths=[]) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - with pytest.raises(CandidateValidationError, match="no new depths"): - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) class TestPersistRelational: @@ -322,16 +280,12 @@ def test_merges_depth_into_target(self): required_depth=DepthLevel.CAPABILITIES, current_depth=DepthLevel.EXISTENCE, ) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = RelationalCandidate( new_depths=[ProposedDepth(level=DepthLevel.CAPABILITIES, properties={"writable": "true"})], ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) saved = repo.saved[0] assert saved.id == target.id levels = {d.level for d in saved.depths} @@ -354,21 +308,16 @@ def test_attaches_relatum_to_source(self): engine = LearningEngine(repo) gap = ReachabilityGap(primitive=source) - grounding = _grounding_result( - grounded=False, gaps=[gap], primitives=[source, target], - ) filled = ReachabilityCandidate( + source_name="Delete", target_name="File", relation_type=RelationType.APPLIES_TO, source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.CAPABILITIES, ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) saved = repo.saved[0] assert saved.id == source.id d2 = next(d for d in saved.depths if d.level == DepthLevel.CAPABILITIES) @@ -377,22 +326,87 @@ def callback(candidate, gr, gap): assert d2.relata[0].relation_type == RelationType.APPLIES_TO assert d2.relata[0].provenance.source == ProvenanceSource.LEARNED - def test_rejects_missing_target_name(self): + def test_attaches_relatum_with_reverse_direction(self): + """Edge FROM a connected node TO the orphan (gap primitive is target).""" + connected = _primitive("FileSystem", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + _depth(DepthLevel.CAPABILITIES), + ]) + orphan = _primitive("Delete", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + _depth(DepthLevel.CAPABILITIES), + ]) + repo = StubRepository([connected, orphan]) + engine = LearningEngine(repo) + + gap = ReachabilityGap(primitive=orphan) + + filled = ReachabilityCandidate( + source_name="FileSystem", + target_name="Delete", + relation_type=RelationType.INCLUDES, + source_depth_level=DepthLevel.CAPABILITIES, + target_depth_level=DepthLevel.CAPABILITIES, + ) + + engine.learn_gap(gap, filled) + saved = repo.saved[0] + assert saved.id == connected.id + d2 = next(d for d in saved.depths if d.level == DepthLevel.CAPABILITIES) + assert len(d2.relata) == 1 + assert d2.relata[0].target_id == orphan.id + + def test_rejects_when_neither_side_is_gap_primitive(self): + """Neither source_name nor target_name matches the gap primitive.""" + source = _primitive("Delete", depths=[_depth(DepthLevel.CAPABILITIES)]) + repo = StubRepository([source]) + engine = LearningEngine(repo) + gap = ReachabilityGap(primitive=source) + + filled = ReachabilityCandidate( + source_name="Unrelated", + target_name="AlsoUnrelated", + relation_type=RelationType.APPLIES_TO, + source_depth_level=DepthLevel.CAPABILITIES, + target_depth_level=DepthLevel.CAPABILITIES, + ) + + with pytest.raises(CandidateValidationError, match="must reference the gapped primitive"): + engine.learn_gap(gap, filled) + + def test_rejects_missing_names(self): + """Empty ReachabilityCandidate with no names at all.""" source = _primitive("Delete", depths=[_depth(DepthLevel.CAPABILITIES)]) repo = StubRepository([source]) engine = LearningEngine(repo) gap = ReachabilityGap(primitive=source) - grounding = _grounding_result(grounded=False, gaps=[gap], primitives=[source]) filled = ReachabilityCandidate() - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED + with pytest.raises(CandidateValidationError, match="missing"): + engine.learn_gap(gap, filled) + + def test_rejects_missing_source_name(self): + """Has target_name but no source_name.""" + source = _primitive("Delete", depths=[_depth(DepthLevel.CAPABILITIES)]) + repo = StubRepository([source]) + engine = LearningEngine(repo) + gap = ReachabilityGap(primitive=source) + + filled = ReachabilityCandidate( + target_name="File", + relation_type=RelationType.APPLIES_TO, + source_depth_level=DepthLevel.CAPABILITIES, + target_depth_level=DepthLevel.CAPABILITIES, + ) with pytest.raises(CandidateValidationError, match="missing"): - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) - def test_learns_missing_source_depth_before_placing_edge(self): + def test_rejects_when_source_depth_missing(self): + """Source does not have the required depth level.""" target = _primitive("File", depths=[ _depth(DepthLevel.EXISTENCE), _depth(DepthLevel.IDENTITY), @@ -402,74 +416,43 @@ def test_learns_missing_source_depth_before_placing_edge(self): repo = StubRepository([source, target]) engine = LearningEngine(repo) gap = ReachabilityGap(primitive=source) - grounding = _grounding_result(grounded=False, gaps=[gap], primitives=[source, target]) - edge_candidate = ReachabilityCandidate( + filled = ReachabilityCandidate( + source_name="Delete", target_name="File", relation_type=RelationType.APPLIES_TO, - source_depth_level=DepthLevel.CONSTRAINTS, + source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.CAPABILITIES, ) - call_count = 0 - - def callback(candidate, gr, gap): - nonlocal call_count - call_count += 1 - if isinstance(candidate, DepthCandidate): - # Agent fills in the missing depths - filled = DepthCandidate(new_depths=[ - ProposedDepth(level=DepthLevel.IDENTITY, properties={"description": "Remove"}), - ProposedDepth(level=DepthLevel.CAPABILITIES, properties={"can_delete": "true"}), - ProposedDepth(level=DepthLevel.CONSTRAINTS, properties={"requires_perm": "true"}), - ]) - return filled, CandidateDecision.ACCEPTED - return edge_candidate, CandidateDecision.ACCEPTED - - engine.learn_at(grounding, 0, callback) - # Callback invoked twice: once for edge, once for source depths - assert call_count == 2 - # Source should have depths + relatum - saved_source = next(s for s in repo.saved if s.id == source.id and any( - r for d in s.depths for r in d.relata - )) - d3 = next(d for d in saved_source.depths if d.level == DepthLevel.CONSTRAINTS) - assert d3.properties == {"requires_perm": "true"} - assert len(d3.relata) == 1 - assert d3.relata[0].target_id == target.id - - def test_abandons_edge_if_depth_learning_rejected(self): - target = _primitive("File", depths=[ + with pytest.raises(CandidateValidationError, match="DepthGap"): + engine.learn_gap(gap, filled) + + def test_rejects_when_target_depth_missing(self): + """Target does not have the required depth level.""" + source = _primitive("Delete", depths=[ _depth(DepthLevel.EXISTENCE), _depth(DepthLevel.IDENTITY), _depth(DepthLevel.CAPABILITIES), ]) - source = _primitive("Delete", depths=[_depth(DepthLevel.EXISTENCE)]) + target = _primitive("File", depths=[_depth(DepthLevel.EXISTENCE)]) repo = StubRepository([source, target]) engine = LearningEngine(repo) gap = ReachabilityGap(primitive=source) - grounding = _grounding_result(grounded=False, gaps=[gap], primitives=[source, target]) - edge_candidate = ReachabilityCandidate( + filled = ReachabilityCandidate( + source_name="Delete", target_name="File", relation_type=RelationType.APPLIES_TO, - source_depth_level=DepthLevel.CONSTRAINTS, + source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.CAPABILITIES, ) - def callback(candidate, gr, gap): - if isinstance(candidate, DepthCandidate): - return None, CandidateDecision.REJECTED - return edge_candidate, CandidateDecision.ACCEPTED + with pytest.raises(CandidateValidationError, match="DepthGap"): + engine.learn_gap(gap, filled) - result = engine.learn_at(grounding, 0, callback) - assert result.decision == CandidateDecision.REJECTED - # No relata should have been placed - for saved in repo.saved: - for d in saved.depths: - assert len(d.relata) == 0 - - def test_skips_depth_learning_when_already_present(self): + def test_places_edge_when_depths_already_present(self): + """When both sides already have the required depths, edge placement succeeds.""" target = _primitive("File", depths=[_depth(DepthLevel.EXISTENCE)]) source = _primitive("Delete", depths=[ _depth(DepthLevel.EXISTENCE), @@ -479,151 +462,75 @@ def test_skips_depth_learning_when_already_present(self): repo = StubRepository([source, target]) engine = LearningEngine(repo) gap = ReachabilityGap(primitive=source) - grounding = _grounding_result(grounded=False, gaps=[gap], primitives=[source, target]) - edge_candidate = ReachabilityCandidate( + filled = ReachabilityCandidate( + source_name="Delete", target_name="File", relation_type=RelationType.APPLIES_TO, source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.EXISTENCE, ) - call_count = 0 - - def callback(candidate, gr, gap): - nonlocal call_count - call_count += 1 - return edge_candidate, CandidateDecision.ACCEPTED - - engine.learn_at(grounding, 0, callback) - # Only called once — no depth learning needed - assert call_count == 1 - # D1 retains original properties + engine.learn_gap(gap, filled) saved = next(s for s in repo.saved if s.id == source.id) + # D1 retains original properties d1 = next(d for d in saved.depths if d.level == DepthLevel.IDENTITY) assert d1.properties == {"description": "Removes content"} + # Edge was placed + d2 = next(d for d in saved.depths if d.level == DepthLevel.CAPABILITIES) + assert len(d2.relata) == 1 + assert d2.relata[0].target_id == target.id def test_rejects_unresolvable_target_name(self): source = _primitive("Delete", depths=[_depth(DepthLevel.CAPABILITIES)]) repo = StubRepository([source]) engine = LearningEngine(repo) gap = ReachabilityGap(primitive=source) - grounding = _grounding_result(grounded=False, gaps=[gap], primitives=[source]) filled = ReachabilityCandidate( + source_name="Delete", target_name="Nonexistent", relation_type=RelationType.APPLIES_TO, source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.CAPABILITIES, ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - with pytest.raises(CandidateValidationError, match="Cannot resolve"): - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) # --------------------------------------------------------------------------- -# Decision flow tests +# learn_gap tests # --------------------------------------------------------------------------- -class TestDecisionFlow: - def test_rejected_does_not_persist(self): +class TestLearnGap: + def test_provenance_defaults_to_learned(self): repo = StubRepository() engine = LearningEngine(repo) gap = ExistenceGap(primitive=_primitive("Copy")) - grounding = _grounding_result(grounded=False, gaps=[gap]) - def callback(candidate, gr, gap): - return None, CandidateDecision.REJECTED - - result = engine.learn_at(grounding, 0, callback) - assert result.decision == CandidateDecision.REJECTED - assert len(repo.saved) == 0 - - def test_skipped_does_not_persist(self): - repo = StubRepository() - engine = LearningEngine(repo) - gap = ExistenceGap(primitive=_primitive("Copy")) - grounding = _grounding_result(grounded=False, gaps=[gap]) - - def callback(candidate, gr, gap): - return None, CandidateDecision.SKIPPED - - result = engine.learn_at(grounding, 0, callback) - assert result.decision == CandidateDecision.SKIPPED - assert len(repo.saved) == 0 - - def test_no_gaps_raises(self): - repo = StubRepository() - engine = LearningEngine(repo) - grounding = _grounding_result(grounded=True, gaps=[]) - - with pytest.raises(CandidateValidationError, match="No gaps"): - engine.learn_at(grounding, 0, lambda c, g, gap: (None, CandidateDecision.REJECTED)) - - -# --------------------------------------------------------------------------- -# learn_at tests -# --------------------------------------------------------------------------- - -class TestLearnAt: - def test_processes_gap_at_index(self): - repo = StubRepository() - engine = LearningEngine(repo) - gap0 = ExistenceGap(primitive=_primitive("Copy")) - gap1 = ExistenceGap(primitive=_primitive("Move")) - grounding = _grounding_result(grounded=False, gaps=[gap0, gap1]) - - received_names = [] - - def callback(candidate, gr, gap): - received_names.append(candidate.name) - return None, CandidateDecision.SKIPPED + filled = ExistenceCandidate( + name="Copy", + d1=ProposedDepth(level=DepthLevel.IDENTITY, properties={"description": "Duplicates"}), + ) - engine.learn_at(grounding, 1, callback) - assert received_names == ["Move"] + engine.learn_gap(gap, filled) + saved = repo.saved[0] + assert saved.provenance.source == ProvenanceSource.LEARNED - def test_out_of_range_raises(self): + def test_provenance_conversational(self): repo = StubRepository() engine = LearningEngine(repo) gap = ExistenceGap(primitive=_primitive("Copy")) - grounding = _grounding_result(grounded=False, gaps=[gap]) - - with pytest.raises(CandidateValidationError, match="out of range"): - engine.learn_at(grounding, 5, lambda c, g, gap: (None, CandidateDecision.REJECTED)) - - -# --------------------------------------------------------------------------- -# Callback lifecycle tests -# --------------------------------------------------------------------------- - -class TestLearningCallbackLifecycle: - def test_default_enter_returns_self(self): - class SimpleLearner(LearningCallback): - def __call__(self, candidate, grounding, gap): - return None, CandidateDecision.REJECTED - - cb = SimpleLearner() - assert cb.__enter__() is cb - def test_default_exit_is_noop(self): - class SimpleLearner(LearningCallback): - def __call__(self, candidate, grounding, gap): - return None, CandidateDecision.REJECTED - - cb = SimpleLearner() - cb.__exit__(None, None, None) # should not raise - - def test_usable_as_context_manager(self): - class SimpleLearner(LearningCallback): - def __call__(self, candidate, grounding, gap): - return None, CandidateDecision.REJECTED + filled = ExistenceCandidate( + name="Copy", + d1=ProposedDepth(level=DepthLevel.IDENTITY, properties={"description": "Duplicates"}), + ) - cb = SimpleLearner() - with cb as entered: - assert entered is cb + engine.learn_gap(gap, filled, source=ProvenanceSource.CONVERSATIONAL) + saved = repo.saved[0] + assert saved.provenance.source == ProvenanceSource.CONVERSATIONAL # --------------------------------------------------------------------------- @@ -646,20 +553,16 @@ class UnknownGap: class TestPersistDepthEdgeCases: def test_primitive_not_found_raises(self): prim = _primitive("Ghost") - repo = StubRepository() # empty — primitive not in repo + repo = StubRepository() # empty -- primitive not in repo engine = LearningEngine(repo) gap = DepthGap(primitive=prim, required_depth=DepthLevel.CAPABILITIES, current_depth=DepthLevel.EXISTENCE) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = DepthCandidate( new_depths=[ProposedDepth(level=DepthLevel.CAPABILITIES, properties={"a": "true"})], ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - with pytest.raises(CandidateValidationError, match="not found"): - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) def test_replaces_existing_depth_level(self): """When a candidate proposes a depth that already exists, it should replace it.""" @@ -670,17 +573,13 @@ def test_replaces_existing_depth_level(self): repo = StubRepository([prim]) engine = LearningEngine(repo) gap = DepthGap(primitive=prim, required_depth=DepthLevel.CAPABILITIES, current_depth=DepthLevel.IDENTITY) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = DepthCandidate(new_depths=[ ProposedDepth(level=DepthLevel.IDENTITY, properties={"updated": "true"}), ProposedDepth(level=DepthLevel.CAPABILITIES, properties={"cap": "true"}), ]) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) saved = repo.saved[0] d1 = next(d for d in saved.depths if d.level == DepthLevel.IDENTITY) assert d1.properties == {"updated": "true"} @@ -713,22 +612,18 @@ def test_preserves_relata_and_stamps_provenance_on_replaced_depth(self): repo = StubRepository([prim]) engine = LearningEngine(repo) gap = DepthGap(primitive=prim, required_depth=DepthLevel.CAPABILITIES, current_depth=DepthLevel.IDENTITY) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = DepthCandidate(new_depths=[ ProposedDepth(level=DepthLevel.IDENTITY, properties={"desc": "a file"}), ProposedDepth(level=DepthLevel.CAPABILITIES, properties={"cap": "read"}), ]) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) saved = repo.saved[0] - # D0 was NOT touched — its relatum should remain unstamped + # D0 was NOT touched -- its relatum should remain unstamped d0 = next(d for d in saved.depths if d.level == DepthLevel.EXISTENCE) assert d0.relata[0].provenance is None - # D1 WAS replaced — relata carried forward and provenance stamped + # D1 WAS replaced -- relata carried forward and provenance stamped d1 = next(d for d in saved.depths if d.level == DepthLevel.IDENTITY) assert len(d1.relata) == 1 assert d1.relata[0].target_id == target_id @@ -748,15 +643,11 @@ def test_empty_new_depths_raises(self): source=source, target=target, required_depth=DepthLevel.CAPABILITIES, current_depth=DepthLevel.EXISTENCE, ) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = RelationalCandidate(new_depths=[]) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - with pytest.raises(CandidateValidationError, match="no new depths"): - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) def test_target_not_found_raises(self): source = _primitive("Create") @@ -767,17 +658,13 @@ def test_target_not_found_raises(self): source=source, target=target, required_depth=DepthLevel.CAPABILITIES, current_depth=None, ) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = RelationalCandidate(new_depths=[ ProposedDepth(level=DepthLevel.CAPABILITIES, properties={"writable": "true"}), ]) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - with pytest.raises(CandidateValidationError, match="not found"): - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) def test_replaces_existing_depth_on_target(self): source = _primitive("Create") @@ -791,16 +678,12 @@ def test_replaces_existing_depth_on_target(self): source=source, target=target, required_depth=DepthLevel.CAPABILITIES, current_depth=DepthLevel.EXISTENCE, ) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = RelationalCandidate(new_depths=[ ProposedDepth(level=DepthLevel.CAPABILITIES, properties={"updated": "true"}), ]) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) saved = repo.saved[0] d2 = next(d for d in saved.depths if d.level == DepthLevel.CAPABILITIES) assert d2.properties == {"updated": "true"} @@ -826,18 +709,14 @@ def test_does_not_stamp_provenance_on_untouched_depths(self): source=source, target=target, required_depth=DepthLevel.CAPABILITIES, current_depth=DepthLevel.EXISTENCE, ) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = RelationalCandidate(new_depths=[ ProposedDepth(level=DepthLevel.CAPABILITIES, properties={"cap": "true"}), ]) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) saved = repo.saved[0] - # D0 was not touched — its relatum provenance should remain None + # D0 was not touched -- its relatum provenance should remain None d0 = next(d for d in saved.depths if d.level == DepthLevel.EXISTENCE) assert d0.relata[0].provenance is None @@ -864,16 +743,12 @@ def test_preserves_relata_on_replaced_target_depth(self): source=source, target=target, required_depth=DepthLevel.CAPABILITIES, current_depth=DepthLevel.EXISTENCE, ) - grounding = _grounding_result(grounded=False, gaps=[gap]) filled = RelationalCandidate(new_depths=[ ProposedDepth(level=DepthLevel.CAPABILITIES, properties={"updated": "true"}), ]) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) saved = repo.saved[0] d2 = next(d for d in saved.depths if d.level == DepthLevel.CAPABILITIES) assert d2.properties == {"updated": "true"} @@ -889,20 +764,17 @@ def test_missing_depth_levels_raises(self): repo = StubRepository([source]) engine = LearningEngine(repo) gap = ReachabilityGap(primitive=source) - grounding = _grounding_result(grounded=False, gaps=[gap], primitives=[source]) filled = ReachabilityCandidate( + source_name="Delete", target_name="File", relation_type=RelationType.APPLIES_TO, source_depth_level=None, target_depth_level=None, ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - with pytest.raises(CandidateValidationError, match="missing"): - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) def test_source_not_found_raises(self): source = _primitive("Ghost") @@ -910,187 +782,45 @@ def test_source_not_found_raises(self): repo = StubRepository([target]) # source not in repo engine = LearningEngine(repo) gap = ReachabilityGap(primitive=source) - grounding = _grounding_result(grounded=False, gaps=[gap], primitives=[source, target]) filled = ReachabilityCandidate( + source_name="Ghost", target_name="File", relation_type=RelationType.APPLIES_TO, source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.CAPABILITIES, ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - with pytest.raises(CandidateValidationError, match="not found"): - engine.learn_at(grounding, 0, callback) + with pytest.raises(CandidateValidationError, match="Cannot resolve"): + engine.learn_gap(gap, filled) def test_target_not_found_raises(self): source = _primitive("Delete", depths=[_depth(DepthLevel.CAPABILITIES)]) - target = _primitive("File", depths=[_depth(DepthLevel.CAPABILITIES)]) repo = StubRepository([source]) # target not in repo engine = LearningEngine(repo) gap = ReachabilityGap(primitive=source) - grounding = _grounding_result(grounded=False, gaps=[gap], primitives=[source, target]) filled = ReachabilityCandidate( + source_name="Delete", target_name="File", relation_type=RelationType.APPLIES_TO, source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.CAPABILITIES, ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - with pytest.raises(CandidateValidationError, match="not found"): - engine.learn_at(grounding, 0, callback) - - def test_learns_missing_target_depth_and_refreshes(self): - """When only the target needs depth learning, verifies the target is refreshed.""" - source = _primitive("Delete", depths=[ - _depth(DepthLevel.EXISTENCE), - _depth(DepthLevel.IDENTITY), - _depth(DepthLevel.CAPABILITIES), - ]) - target = _primitive("File", depths=[_depth(DepthLevel.EXISTENCE)]) - repo = StubRepository([source, target]) - engine = LearningEngine(repo) - gap = ReachabilityGap(primitive=source) - grounding = _grounding_result(grounded=False, gaps=[gap], primitives=[source, target]) - - edge_candidate = ReachabilityCandidate( - target_name="File", - relation_type=RelationType.APPLIES_TO, - source_depth_level=DepthLevel.CAPABILITIES, - target_depth_level=DepthLevel.CAPABILITIES, - ) - - def callback(candidate, gr, gap): - if isinstance(candidate, DepthCandidate): - filled = DepthCandidate(new_depths=[ - ProposedDepth(level=DepthLevel.IDENTITY, properties={"desc": "a file"}), - ProposedDepth(level=DepthLevel.CAPABILITIES, properties={"readable": "true"}), - ]) - return filled, CandidateDecision.ACCEPTED - return edge_candidate, CandidateDecision.ACCEPTED - - result = engine.learn_at(grounding, 0, callback) - assert result.decision == CandidateDecision.ACCEPTED - # Edge should be placed on source - saved_source = next(s for s in repo.saved if s.id == source.id and any( - r for d in s.depths for r in d.relata - )) - d2 = next(d for d in saved_source.depths if d.level == DepthLevel.CAPABILITIES) - assert len(d2.relata) == 1 - assert d2.relata[0].target_id == target.id - - def test_rejects_target_depth_learning_abandons_edge(self): - """When target depth learning is rejected, edge placement is abandoned.""" - source = _primitive("Delete", depths=[ - _depth(DepthLevel.EXISTENCE), - _depth(DepthLevel.IDENTITY), - _depth(DepthLevel.CAPABILITIES), - ]) - target = _primitive("File", depths=[_depth(DepthLevel.EXISTENCE)]) - repo = StubRepository([source, target]) - engine = LearningEngine(repo) - gap = ReachabilityGap(primitive=source) - grounding = _grounding_result(grounded=False, gaps=[gap], primitives=[source, target]) - - edge_candidate = ReachabilityCandidate( - target_name="File", - relation_type=RelationType.APPLIES_TO, - source_depth_level=DepthLevel.CAPABILITIES, - target_depth_level=DepthLevel.CAPABILITIES, - ) - - def callback(candidate, gr, gap): - if isinstance(candidate, DepthCandidate): - return None, CandidateDecision.REJECTED - return edge_candidate, CandidateDecision.ACCEPTED - - result = engine.learn_at(grounding, 0, callback) - assert result.decision == CandidateDecision.REJECTED - - def test_skips_source_depth_learning_abandons_edge(self): - """When source depth learning is skipped, edge placement is abandoned.""" - source = _primitive("Delete", depths=[_depth(DepthLevel.EXISTENCE)]) - target = _primitive("File", depths=[ - _depth(DepthLevel.EXISTENCE), - _depth(DepthLevel.CAPABILITIES), - ]) - repo = StubRepository([source, target]) - engine = LearningEngine(repo) - gap = ReachabilityGap(primitive=source) - grounding = _grounding_result(grounded=False, gaps=[gap], primitives=[source, target]) - - edge_candidate = ReachabilityCandidate( - target_name="File", - relation_type=RelationType.APPLIES_TO, - source_depth_level=DepthLevel.CAPABILITIES, - target_depth_level=DepthLevel.CAPABILITIES, - ) - - def callback(candidate, gr, gap): - if isinstance(candidate, DepthCandidate): - return None, CandidateDecision.SKIPPED - return edge_candidate, CandidateDecision.ACCEPTED - - result = engine.learn_at(grounding, 0, callback) - assert result.decision == CandidateDecision.SKIPPED - - -class TestLearnMissingDepthsEdgeCases: - def test_filled_none_treated_as_rejected(self): - """If callback returns filled=None with ACCEPTED, treat as REJECTED.""" - prim = _primitive("File", depths=[_depth(DepthLevel.EXISTENCE)]) - repo = StubRepository([prim]) - engine = LearningEngine(repo) - - # Call _learn_missing_depths directly - grounding = _grounding_result(grounded=False) - - def callback(candidate, gr, gap): - return None, CandidateDecision.ACCEPTED - - result = engine._learn_missing_depths( - prim, DepthLevel.CAPABILITIES, grounding, callback, - ) - assert result == CandidateDecision.REJECTED + with pytest.raises(CandidateValidationError, match="Cannot resolve"): + engine.learn_gap(gap, filled) # --------------------------------------------------------------------------- # Cycle detection # --------------------------------------------------------------------------- -def _reachability_grounding( - source: Primitive, - target: Primitive, - all_primitives: list[Primitive], - pathway: list[EpistemicStep] | None = None, -) -> tuple[ReachabilityGap, GroundingResult]: - """Build a ReachabilityGap + GroundingResult with a trace for cycle tests.""" - gap = ReachabilityGap(primitive=source) - trace = EpistemicResponse( - query=EpistemicQuery(concept_ids=[]), - result=EpistemicResult( - primitives=all_primitives, - gaps=[gap], - pathway=pathway or [], - ), - ) - grounding = GroundingResult( - grounded=False, resolved=[], gaps=[gap], trace=trace, - ) - return gap, grounding - - class TestCycleDetection: - """Cycle detection via save_primitive → CyclicRelationshipError propagated.""" + """Cycle detection via save_primitive -> CyclicRelationshipError propagated.""" def test_self_referential_transitive_raises(self): - """A→A via REQUIRES is a trivial cycle → CyclicRelationshipError.""" + """A->A via REQUIRES is a trivial cycle -> CyclicRelationshipError.""" a = _primitive("A", depths=[ _depth(DepthLevel.EXISTENCE), _depth(DepthLevel.IDENTITY), @@ -1098,23 +828,21 @@ def test_self_referential_transitive_raises(self): ]) repo = StubRepository([a]) engine = LearningEngine(repo) - gap, grounding = _reachability_grounding(a, a, [a]) + gap = ReachabilityGap(primitive=a) filled = ReachabilityCandidate( + source_name="A", target_name="A", relation_type=RelationType.REQUIRES, source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.CAPABILITIES, ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - with pytest.raises(CyclicRelationshipError): - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) def test_direct_cycle_raises(self): - """A→B via REQUIRES exists; B→A via REQUIRES would cycle → CyclicRelationshipError.""" + """A->B via REQUIRES exists; B->A via REQUIRES would cycle -> CyclicRelationshipError.""" b = _primitive("B", depths=[ _depth(DepthLevel.EXISTENCE), _depth(DepthLevel.IDENTITY), @@ -1137,23 +865,21 @@ def test_direct_cycle_raises(self): ]) repo = StubRepository([a, b]) engine = LearningEngine(repo) - gap, grounding = _reachability_grounding(b, a, [a, b]) + gap = ReachabilityGap(primitive=b) filled = ReachabilityCandidate( + source_name="B", target_name="A", relation_type=RelationType.REQUIRES, source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.CAPABILITIES, ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - with pytest.raises(CyclicRelationshipError): - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) def test_indirect_cycle_raises(self): - """A→B→C via DEPENDS_ON exists; C→A would cycle → CyclicRelationshipError.""" + """A->B->C via DEPENDS_ON exists; C->A would cycle -> CyclicRelationshipError.""" c = _primitive("C", depths=[ _depth(DepthLevel.EXISTENCE), _depth(DepthLevel.IDENTITY), @@ -1191,23 +917,21 @@ def test_indirect_cycle_raises(self): ]) repo = StubRepository([a, b, c]) engine = LearningEngine(repo) - gap, grounding = _reachability_grounding(c, a, [a, b, c]) + gap = ReachabilityGap(primitive=c) filled = ReachabilityCandidate( + source_name="C", target_name="A", relation_type=RelationType.DEPENDS_ON, source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.CAPABILITIES, ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - with pytest.raises(CyclicRelationshipError): - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) def test_mixed_transitive_types_cycle_raises(self): - """A→B via REQUIRES exists; B→A via CONSTRAINED_BY would cycle → CyclicRelationshipError.""" + """A->B via REQUIRES exists; B->A via CONSTRAINED_BY would cycle -> CyclicRelationshipError.""" b = _primitive("B", depths=[ _depth(DepthLevel.EXISTENCE), _depth(DepthLevel.IDENTITY), @@ -1230,23 +954,21 @@ def test_mixed_transitive_types_cycle_raises(self): ]) repo = StubRepository([a, b]) engine = LearningEngine(repo) - gap, grounding = _reachability_grounding(b, a, [a, b]) + gap = ReachabilityGap(primitive=b) filled = ReachabilityCandidate( + source_name="B", target_name="A", relation_type=RelationType.CONSTRAINED_BY, source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.CAPABILITIES, ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - with pytest.raises(CyclicRelationshipError): - engine.learn_at(grounding, 0, callback) + engine.learn_gap(gap, filled) def test_non_transitive_cycle_allowed(self): - """A→B via APPLIES_TO exists; B→A via APPLIES_TO is fine → ACCEPTED.""" + """A->B via APPLIES_TO exists; B->A via APPLIES_TO is fine.""" b = _primitive("B", depths=[ _depth(DepthLevel.EXISTENCE), _depth(DepthLevel.IDENTITY), @@ -1269,23 +991,23 @@ def test_non_transitive_cycle_allowed(self): ]) repo = StubRepository([a, b]) engine = LearningEngine(repo) - gap, grounding = _reachability_grounding(b, a, [a, b]) + gap = ReachabilityGap(primitive=b) filled = ReachabilityCandidate( + source_name="B", target_name="A", relation_type=RelationType.APPLIES_TO, source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.CAPABILITIES, ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - result = engine.learn_at(grounding, 0, callback) - assert result.decision == CandidateDecision.ACCEPTED + engine.learn_gap(gap, filled) + saved = repo.saved[0] + d2 = next(d for d in saved.depths if d.level == DepthLevel.CAPABILITIES) + assert len(d2.relata) == 1 def test_non_transitive_self_ref_allowed(self): - """A→A via INCLUDES is fine → ACCEPTED.""" + """A->A via INCLUDES is fine.""" a = _primitive("A", depths=[ _depth(DepthLevel.EXISTENCE), _depth(DepthLevel.IDENTITY), @@ -1293,23 +1015,21 @@ def test_non_transitive_self_ref_allowed(self): ]) repo = StubRepository([a]) engine = LearningEngine(repo) - gap, grounding = _reachability_grounding(a, a, [a]) + gap = ReachabilityGap(primitive=a) filled = ReachabilityCandidate( + source_name="A", target_name="A", relation_type=RelationType.INCLUDES, source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.CAPABILITIES, ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - result = engine.learn_at(grounding, 0, callback) - assert result.decision == CandidateDecision.ACCEPTED + engine.learn_gap(gap, filled) + assert len(repo.saved) == 1 def test_valid_transitive_edge_accepted(self): - """A→B via REQUIRES with no path B→A → ACCEPTED.""" + """A->B via REQUIRES with no path B->A.""" b = _primitive("B", depths=[ _depth(DepthLevel.EXISTENCE), _depth(DepthLevel.IDENTITY), @@ -1322,20 +1042,17 @@ def test_valid_transitive_edge_accepted(self): ]) repo = StubRepository([a, b]) engine = LearningEngine(repo) - gap, grounding = _reachability_grounding(a, b, [a, b]) + gap = ReachabilityGap(primitive=a) filled = ReachabilityCandidate( + source_name="A", target_name="B", relation_type=RelationType.REQUIRES, source_depth_level=DepthLevel.CAPABILITIES, target_depth_level=DepthLevel.CAPABILITIES, ) - def callback(candidate, gr, gap): - return filled, CandidateDecision.ACCEPTED - - result = engine.learn_at(grounding, 0, callback) - assert result.decision == CandidateDecision.ACCEPTED + engine.learn_gap(gap, filled) saved = repo.saved[0] d2 = next(d for d in saved.depths if d.level == DepthLevel.CAPABILITIES) assert len(d2.relata) == 1 @@ -1383,8 +1100,8 @@ def test_save_raises_on_two_node_cycle(self): )], ), ]) - repo = StubRepository([a]) # A→B already in repo - # Now try to save B with B→A + repo = StubRepository([a]) # A->B already in repo + # Now try to save B with B->A b_with_edge = _primitive("B", depths=[ Depth( level=DepthLevel.CAPABILITIES, diff --git a/tests/vre/test_metrics.py b/tests/vre/test_metrics.py index ce4d093..d47ecd2 100644 --- a/tests/vre/test_metrics.py +++ b/tests/vre/test_metrics.py @@ -20,13 +20,6 @@ ResolvedSubgraph, ) from vre.core.grounding import GroundingResult -from vre.learning.callback import LearningCallback -from vre.learning.models import ( - CandidateDecision, - DepthCandidate, - ExistenceCandidate, - ProposedDepth, -) # --------------------------------------------------------------------------- @@ -126,27 +119,6 @@ def _make_vre(primitives: list[Primitive]) -> tuple[VRE, StubRepository]: return VRE(repo, persist_traces=False), repo -class _AcceptLearner(LearningCallback): - def __call__(self, candidate, grounding, gap): - if isinstance(candidate, ExistenceCandidate): - filled = ExistenceCandidate( - name=candidate.name, - d1=ProposedDepth(level=DepthLevel.IDENTITY, properties={"desc": "test"}), - ) - return filled, CandidateDecision.ACCEPTED - if isinstance(candidate, DepthCandidate): - filled = DepthCandidate(new_depths=[ - ProposedDepth(level=gap.required_depth, properties={"test": True}), - ]) - return filled, CandidateDecision.ACCEPTED - return None, CandidateDecision.SKIPPED - - -class _RejectLearner(LearningCallback): - def __call__(self, candidate, grounding, gap): - return None, CandidateDecision.REJECTED - - # --------------------------------------------------------------------------- # PrimitiveMetrics model tests # --------------------------------------------------------------------------- @@ -156,8 +128,6 @@ def test_defaults(self): m = PrimitiveMetrics() assert m.grounding_count == 0 assert m.failure_count == 0 - assert m.learning_count == 0 - assert m.rejection_count == 0 assert m.last_grounded is None assert m.last_failed is None @@ -271,40 +241,6 @@ def test_nonexistent_concept_does_not_crash(self): assert result.grounded is False -# --------------------------------------------------------------------------- -# Learning metrics tests -# --------------------------------------------------------------------------- - -class TestLearningMetrics: - def test_learn_all_accepted_increments_learning_count(self): - file_p = _make_fully_grounded("file") - vre, repo = _make_vre([file_p]) - - grounding = vre.check(["file", "copy"]) - assert grounding.grounded is False - - vre.learn_all(grounding, _AcceptLearner(), ["file", "copy"]) - - created = repo.find_by_name("copy") - assert created is not None - assert created.metrics is not None - # learning_count tracks accepted learning events on this primitive - assert created.metrics.learning_count >= 1 - # Re-grounding after learning also updates grounding metrics - assert created.metrics.last_exercised is not None - - def test_learn_all_rejected_increments_rejection_count(self): - file_p = _make_fully_grounded("file") - vre, repo = _make_vre([file_p]) - - grounding = vre.check(["file", "copy"]) - vre.learn_all(grounding, _RejectLearner(), ["file", "copy"]) - - # "copy" was never created (rejected), so we can't track metrics on it. - # This verifies the code handles the case gracefully without crashing. - assert repo.find_by_name("copy") is None - - # --------------------------------------------------------------------------- # Serialization round-trip tests # --------------------------------------------------------------------------- @@ -316,15 +252,11 @@ def test_roundtrip_via_model_dump(self): last_failed=datetime(2026, 3, 10, 8, 0, 0, tzinfo=timezone.utc), grounding_count=42, failure_count=3, - learning_count=5, - rejection_count=1, ) data = m.model_dump(mode="json") restored = PrimitiveMetrics(**data) assert restored.grounding_count == 42 assert restored.failure_count == 3 - assert restored.learning_count == 5 - assert restored.rejection_count == 1 assert restored.last_grounded == m.last_grounded assert restored.last_failed == m.last_failed diff --git a/tests/vre/test_tracing.py b/tests/vre/test_tracing.py index 47d5912..9e0fff7 100644 --- a/tests/vre/test_tracing.py +++ b/tests/vre/test_tracing.py @@ -22,13 +22,6 @@ RelationType, ResolvedSubgraph, ) -from vre.learning.callback import LearningCallback -from vre.learning.models import ( - CandidateDecision, - ExistenceCandidate, - LearningResult, - ProposedDepth, -) import vre.tracing as tracing_module from vre.tracing import TraceWriter, build_trace_entry @@ -181,25 +174,8 @@ def test_check_operation(self): assert entry.grounded is True assert entry.gaps == [] assert len(entry.steps) == 1 - assert entry.learning_outcomes is None assert entry.timestamp is not None - def test_learn_operation_with_outcomes(self): - result = _grounding_result(grounded=True) - outcomes = [ - LearningResult( - decision=CandidateDecision.ACCEPTED, - candidate=ExistenceCandidate(name="copy", d1=ProposedDepth(level=DepthLevel.IDENTITY)), - ), - ] - entry = build_trace_entry("learn", ["file", "copy"], result, outcomes) - - assert entry.operation == "learn" - assert entry.learning_outcomes is not None - assert len(entry.learning_outcomes) == 1 - assert entry.learning_outcomes[0]["decision"] == "accepted" - assert entry.learning_outcomes[0]["candidate"]["kind"] == "EXISTENCE" - def test_no_trace_gives_empty_steps(self): result = _grounding_result(with_trace=False) entry = build_trace_entry("check", ["file"], result) @@ -252,7 +228,7 @@ def test_each_field_present_in_json(self): expected_keys = { "timestamp", "operation", "concepts", "resolved", - "grounded", "gaps", "steps", "agent_id", "learning_outcomes", + "grounded", "gaps", "steps", "agent_id", } assert set(parsed.keys()) == expected_keys @@ -361,64 +337,3 @@ def test_trace_entry_content_structure(self, tmp_path, monkeypatch): # Existence gap should have kind field existence_gaps = [g for g in parsed["gaps"] if g["kind"] == "EXISTENCE"] assert len(existence_gaps) >= 1 - - def test_learn_all_writes_single_trace_with_outcomes(self, tmp_path, monkeypatch): - """learn_all suppresses intermediate check() traces and writes one learn entry.""" - monkeypatch.setattr(tracing_module, "DEFAULT_TRACE_DIR", tmp_path / "traces") - file_p = _make_fully_grounded("file") - repo = StubRepository([file_p]) - vre = VRE(repo) - - grounding = vre.check(["file", "copy"]) - assert grounding.grounded is False - - class AcceptLearner(LearningCallback): - def __call__(self, candidate, grounding, gap): - if isinstance(candidate, ExistenceCandidate): - filled = ExistenceCandidate( - name=candidate.name, - d1=ProposedDepth(level=DepthLevel.IDENTITY, properties={"desc": "test"}), - ) - return filled, CandidateDecision.ACCEPTED - return None, CandidateDecision.SKIPPED - - vre.learn_all(grounding, AcceptLearner(), ["file", "copy"]) - - files = list((tmp_path / "traces").glob("*.jsonl")) - assert len(files) == 1 - lines = files[0].read_text().strip().split("\n") - - # One "check" from the initial check(), one "learn" from learn_all() - # Intermediate check() calls inside learn_all() should be suppressed - operations = [json.loads(line)["operation"] for line in lines] - assert operations.count("check") == 1 - assert operations.count("learn") == 1 - - # The learn entry should have learning_outcomes - learn_entry = json.loads(lines[operations.index("learn")]) - assert learn_entry["learning_outcomes"] is not None - assert len(learn_entry["learning_outcomes"]) >= 1 - - def test_learn_all_no_trace_without_outcomes(self, tmp_path, monkeypatch): - """learn_all does not write a trace when there are no learning outcomes.""" - monkeypatch.setattr(tracing_module, "DEFAULT_TRACE_DIR", tmp_path / "traces") - file_p = _make_fully_grounded("file") - repo = StubRepository([file_p]) - vre = VRE(repo) - - # Grounded result — learn_all loop body never executes - grounding = vre.check(["file"]) - assert grounding.grounded is True - - vre.learn_all(grounding, _NullLearner(), ["file"]) - - files = list((tmp_path / "traces").glob("*.jsonl")) - lines = files[0].read_text().strip().split("\n") - # Only the initial check() trace, no learn trace - operations = [json.loads(line)["operation"] for line in lines] - assert operations == ["check"] - - -class _NullLearner(LearningCallback): - def __call__(self, candidate, grounding, gap): - return None, CandidateDecision.SKIPPED diff --git a/tests/vre/test_vre.py b/tests/vre/test_vre.py index c39842e..1dc00e2 100644 --- a/tests/vre/test_vre.py +++ b/tests/vre/test_vre.py @@ -19,14 +19,8 @@ ResolvedSubgraph, ) from vre.core.policy import Cardinality, Policy, PolicyAction, PolicyResult -from vre.core.grounding import GroundingResult -from vre.learning.callback import LearningCallback -from vre.learning.models import ( - CandidateDecision, - DepthCandidate, - ExistenceCandidate, - ProposedDepth, -) +from vre.core.grounding import GroundingResult, ConceptResolver +from vre.learning import LearningEngine # --------------------------------------------------------------------------- @@ -341,119 +335,18 @@ def handler(violations): # --------------------------------------------------------------------------- -# VRE.learn_all tests +# VRE property tests # --------------------------------------------------------------------------- -class _AcceptLearner(LearningCallback): - """Callback that accepts proposals with appropriate candidates.""" - - def __init__(self): - self.calls = [] - - def __call__(self, candidate, grounding, gap): - self.calls.append((candidate, gap)) - if isinstance(candidate, ExistenceCandidate): - filled = ExistenceCandidate( - name=candidate.name, - d1=ProposedDepth(level=DepthLevel.IDENTITY, properties={"desc": "test"}), - ) - return filled, CandidateDecision.ACCEPTED - if isinstance(candidate, DepthCandidate): - filled = DepthCandidate(new_depths=[ - ProposedDepth(level=gap.required_depth, properties={"test": True}), - ]) - return filled, CandidateDecision.ACCEPTED - return None, CandidateDecision.SKIPPED - - -class _RejectLearner(LearningCallback): - """Callback that rejects all proposals.""" - - def __call__(self, candidate, grounding, gap): - return None, CandidateDecision.REJECTED - - -class TestVRELearnAll: - def test_learn_all_resolves_existence_gap(self): - """learn_all creates the missing primitive and returns grounded.""" - file_p = _make_fully_grounded("file") - vre = _make_vre_with_stub([file_p]) - - grounding = vre.check(["file", "copy"]) - assert grounding.grounded is False - - learner = _AcceptLearner() - result = vre.learn_all(grounding, learner, ["file", "copy"]) - # After learning "copy", re-grounding should find it (now in the repo) - assert isinstance(result, GroundingResult) - - def test_learn_all_stops_on_rejection(self): - """learn_all stops the loop when callback rejects.""" - file_p = _make_fully_grounded("file") - vre = _make_vre_with_stub([file_p]) - - grounding = vre.check(["file", "copy"]) - result = vre.learn_all(grounding, _RejectLearner(), ["file", "copy"]) - assert isinstance(result, GroundingResult) - assert result.grounded is False - - def test_learn_all_skips_gaps_and_continues(self): - """learn_all skips a gap and continues to the next.""" - file_p = _make_fully_grounded("file") - vre = _make_vre_with_stub([file_p]) - - grounding = vre.check(["file", "alpha", "beta"]) - assert len([g for g in grounding.gaps if g.kind == "EXISTENCE"]) == 2 - - call_count = 0 - - class SkipThenAccept(LearningCallback): - def __call__(self, candidate, grounding, gap): - nonlocal call_count - call_count += 1 - if call_count == 1: - return None, CandidateDecision.SKIPPED - if isinstance(candidate, ExistenceCandidate): - filled = ExistenceCandidate( - name=candidate.name, - d1=ProposedDepth(level=DepthLevel.IDENTITY, properties={"desc": "test"}), - ) - return filled, CandidateDecision.ACCEPTED - # Skip any non-existence gaps (e.g. ReachabilityGap) - return None, CandidateDecision.SKIPPED - - result = vre.learn_all(grounding, SkipThenAccept(), ["file", "alpha", "beta"]) - assert isinstance(result, GroundingResult) - # At least 2 calls: one skip, one accept - assert call_count >= 2 - - def test_learn_all_uses_context_manager(self): - """learn_all wraps the callback in a context manager.""" - file_p = _make_fully_grounded("file") - vre = _make_vre_with_stub([file_p]) - - grounding = vre.check(["file", "unknown"]) - - entered = False - exited = False - - class LifecycleLearner(LearningCallback): - def __enter__(self): - nonlocal entered - entered = True - return self - - def __exit__(self, *args): - nonlocal exited - exited = True - - def __call__(self, candidate, grounding, gap): - return None, CandidateDecision.REJECTED +class TestVREProperties: + def test_learning_engine_property(self): + vre = _make_vre_with_stub([]) + assert isinstance(vre.learning_engine, LearningEngine) - vre.learn_all(grounding, LifecycleLearner(), ["file", "unknown"]) - assert entered - assert exited + def test_resolver_property(self): + vre = _make_vre_with_stub([]) + assert isinstance(vre.resolver, ConceptResolver) # ---------------------------------------------------------------------------