vre 0.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vre/__init__.py ADDED
@@ -0,0 +1,440 @@
1
+ # Copyright 2026 Andrew Greene
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """
5
+ Volute Reasoning Engine — decorator-based epistemic enforcement.
6
+
7
+ Usage::
8
+
9
+ from vre import VRE
10
+ from vre.core.graph import PrimitiveRepository
11
+
12
+ repo = PrimitiveRepository("neo4j://localhost:7687", "neo4j", "password")
13
+ vre = VRE(repo)
14
+ result = vre.check(["file", "write"])
15
+ print(result.grounded, result.resolved)
16
+ """
17
+
18
+ import logging
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Callable
22
+ from uuid import UUID
23
+
24
+ from vre.core.graph import PrimitiveRepository
25
+ from vre.core.grounding import ConceptResolver, GroundingEngine, GroundingResult
26
+ from vre.core.errors import (
27
+ CandidateValidationError,
28
+ CyclicRelationshipError,
29
+ GraphError,
30
+ GraphIntegrityError,
31
+ HydrationError,
32
+ PersistenceError,
33
+ RegistryError,
34
+ ResolutionError,
35
+ VREError,
36
+ )
37
+ from vre.core.models import (
38
+ DepthGap,
39
+ DepthLevel,
40
+ ExistenceGap,
41
+ PrimitiveMetrics,
42
+ Provenance,
43
+ ProvenanceSource,
44
+ ReachabilityGap,
45
+ RelationalGap,
46
+ )
47
+ from vre.core.policy import Cardinality, PolicyAction, PolicyCallbackResult, PolicyResult, PolicyViolation
48
+ from vre.core.policy.callback import PolicyCallContext
49
+ from vre.core.policy.gate import PolicyGate
50
+ from vre.identity import AgentIdentity, AgentRegistry
51
+ from vre.learning import (
52
+ CandidateDecision,
53
+ LearningCallback,
54
+ LearningCandidate,
55
+ LearningEngine,
56
+ LearningResult,
57
+ )
58
+ from vre.tracing import TraceWriter, build_trace_entry
59
+
60
+ logging.getLogger("vre").addHandler(logging.NullHandler())
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+ __all__ = [
65
+ "VRE",
66
+ "AgentIdentity",
67
+ "AgentRegistry",
68
+ "CandidateValidationError",
69
+ "CyclicRelationshipError",
70
+ "GraphError",
71
+ "GraphIntegrityError",
72
+ "HydrationError",
73
+ "PersistenceError",
74
+ "RegistryError",
75
+ "ResolutionError",
76
+ "VREError",
77
+ "PrimitiveRepository",
78
+ "ConceptResolver",
79
+ "GroundingEngine",
80
+ "GroundingResult",
81
+ "DepthLevel",
82
+ "PrimitiveMetrics",
83
+ "Provenance",
84
+ "ProvenanceSource",
85
+ "Cardinality",
86
+ "PolicyAction",
87
+ "PolicyCallbackResult",
88
+ "PolicyResult",
89
+ "PolicyViolation",
90
+ "PolicyCallContext",
91
+ "PolicyGate",
92
+ "CandidateDecision",
93
+ "LearningCallback",
94
+ "LearningCandidate",
95
+ "LearningEngine",
96
+ "LearningResult",
97
+ ]
98
+
99
+
100
+ class VRE:
101
+ """
102
+ Volute Reasoning Engine — public interface.
103
+
104
+ Wraps ConceptResolver and GroundingEngine. Depth requirements are
105
+ derived from graph structure; an optional min_depth override lets
106
+ integrators enforce a stricter floor.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ repository: PrimitiveRepository,
112
+ agent_key: str | None = None,
113
+ agent_name: str | None = None,
114
+ registry_path: str | Path | None = None,
115
+ persist_traces: bool = True,
116
+ ) -> None:
117
+ """
118
+ Initialize VRE with the given primitive repository.
119
+
120
+ Parameters
121
+ ----------
122
+ repository:
123
+ The Neo4j primitive repository for graph operations.
124
+ agent_key:
125
+ Optional registration key for agent identity. When provided,
126
+ the key is resolved via the persisted registry to a stable
127
+ AgentIdentity whose `agent_id` is stamped on every
128
+ GroundingResult produced by this instance.
129
+ agent_name:
130
+ Optional human-readable name for the agent. Only used on
131
+ first registration; ignored on subsequent calls with the
132
+ same `agent_key`.
133
+ registry_path:
134
+ Path to the agent registry JSON file. Defaults to
135
+ `AgentRegistry`'s built-in default when None.
136
+ persist_traces:
137
+ When True (default), grounding traces are persisted to daily
138
+ JSONL files under `~/.vre/traces/`.
139
+ """
140
+ self._repo = repository
141
+ self._resolver = ConceptResolver(repository)
142
+ self._engine = GroundingEngine(repository)
143
+ self._learning_engine = LearningEngine(repository)
144
+
145
+ if agent_key is not None:
146
+ self._identity: AgentIdentity | None = AgentRegistry(registry_path).get_or_create(agent_key, name=agent_name)
147
+ else:
148
+ self._identity = None
149
+
150
+ self._suppress_trace = False
151
+ self._trace_writer: TraceWriter | None = TraceWriter() if persist_traces else None
152
+
153
+ @property
154
+ def identity(self) -> AgentIdentity | None:
155
+ """
156
+ The agent identity associated with this VRE instance, if any.
157
+ """
158
+ return self._identity
159
+
160
+ def _stamp_identity(self, result: GroundingResult) -> GroundingResult:
161
+ """
162
+ Set `agent_id` on the result if this instance has an identity and the result doesn't already have one.
163
+ """
164
+ if self._identity is not None and result.agent_id is None:
165
+ result.agent_id = self._identity.agent_id
166
+ return result
167
+
168
+ @staticmethod
169
+ def _gap_primitive_ids(gaps: list) -> set[UUID]:
170
+ """
171
+ Collect the set of primitive UUIDs that are epistemically deficient.
172
+
173
+ RelationalGaps contribute only the target ID — the relationship
174
+ is a pure structural declaration and the source is epistemically
175
+ sound if the edge is visible. The failure belongs to the target
176
+ that lacks the required depth.
177
+ """
178
+ ids: set[UUID] = set()
179
+ for gap in gaps:
180
+ if gap.kind == "RELATIONAL":
181
+ ids.add(gap.target.id)
182
+ else:
183
+ ids.add(gap.primitive.id)
184
+ return ids
185
+
186
+ def _update_grounding_metrics(self, result: GroundingResult) -> None:
187
+ """
188
+ Update per-primitive grounding metrics after a `check` call.
189
+
190
+ Batch-reads current metrics for all resolved root concepts, computes
191
+ increments in-process, and batch-writes the results. Updates are
192
+ best-effort — failures are logged but never block the caller.
193
+ """
194
+ if not result.trace:
195
+ return
196
+
197
+ now = datetime.now(timezone.utc)
198
+ gap_ids = self._gap_primitive_ids(result.gaps)
199
+ resolved_lower = {r.lower() for r in result.resolved}
200
+
201
+ target_prims = [
202
+ prim for prim in result.trace.result.primitives
203
+ if prim.name.lower() in resolved_lower
204
+ ]
205
+ if not target_prims:
206
+ return
207
+
208
+ target_ids = [p.id for p in target_prims]
209
+
210
+ try:
211
+ current_metrics = self._repo.batch_read_metrics(target_ids)
212
+ except Exception:
213
+ logger.warning("Failed to batch-read metrics", exc_info=True)
214
+ return
215
+
216
+ updates: dict[UUID, PrimitiveMetrics] = {}
217
+ for prim in target_prims:
218
+ if prim.id not in current_metrics:
219
+ continue
220
+ metrics = current_metrics[prim.id] or PrimitiveMetrics()
221
+
222
+ if prim.id in gap_ids:
223
+ metrics.failure_count += 1
224
+ metrics.last_failed = now
225
+ else:
226
+ metrics.grounding_count += 1
227
+ metrics.last_grounded = now
228
+
229
+ updates[prim.id] = metrics
230
+
231
+ if updates:
232
+ try:
233
+ self._repo.batch_update_metrics(updates)
234
+ except Exception:
235
+ logger.warning("Failed to batch-update metrics for %d primitives", len(updates), exc_info=True)
236
+
237
+ def _update_learning_metric(
238
+ self,
239
+ gap: DepthGap | ExistenceGap | RelationalGap | ReachabilityGap,
240
+ decision: CandidateDecision,
241
+ ) -> None:
242
+ """
243
+ Update learning metrics on the primitive targeted by a gap.
244
+
245
+ Increments `learning_count` for accepted/modified decisions and
246
+ `rejection_count` for rejected decisions. Skipped decisions are
247
+ ignored. Looks up the primitive by ID first, falling back to name
248
+ for ExistenceGaps where the gap carries a transient ID.
249
+ """
250
+ if decision == CandidateDecision.SKIPPED:
251
+ return
252
+
253
+ if isinstance(gap, RelationalGap):
254
+ prim_id, prim_name = gap.target.id, gap.target.name
255
+ elif isinstance(gap, (DepthGap, ExistenceGap, ReachabilityGap)):
256
+ prim_id, prim_name = gap.primitive.id, gap.primitive.name
257
+ else:
258
+ return
259
+
260
+ found = self._repo.find_by_id(prim_id)
261
+ if found is None:
262
+ found = self._repo.find_by_name(prim_name)
263
+ if found is None:
264
+ return
265
+
266
+ metrics = found.metrics or PrimitiveMetrics()
267
+
268
+ if decision in (CandidateDecision.ACCEPTED, CandidateDecision.MODIFIED):
269
+ metrics.learning_count += 1
270
+ elif decision == CandidateDecision.REJECTED:
271
+ metrics.rejection_count += 1
272
+
273
+ try:
274
+ self._repo.update_metrics(found.id, metrics)
275
+ except Exception:
276
+ logger.warning("Failed to update learning metrics for %r", prim_name, exc_info=True)
277
+
278
+ def resolve(self, concepts: list[str]) -> list[str]:
279
+ """
280
+ Resolve free-form concept names to canonical primitive names.
281
+ """
282
+ return self._resolver.resolve(concepts)
283
+
284
+ def check(
285
+ self,
286
+ concepts: list[str],
287
+ min_depth: DepthLevel | None = None,
288
+ ) -> GroundingResult:
289
+ """
290
+ Ground concepts with graph-derived depth gating.
291
+
292
+ Returns a GroundingResult with grounded=True only when all resolved
293
+ concepts are fully grounded with no gaps.
294
+
295
+ Parameters
296
+ ----------
297
+ concepts:
298
+ List of free-form concept names to ground.
299
+ min_depth:
300
+ Optional integrator override — enforces a minimum depth floor
301
+ on all root primitives. Can only raise the floor, never lower it.
302
+ """
303
+ result = self._stamp_identity(self._engine.ground(concepts, self._resolver, min_depth=min_depth))
304
+ self._update_grounding_metrics(result)
305
+ if self._trace_writer is not None and not self._suppress_trace:
306
+ try:
307
+ self._trace_writer.write(build_trace_entry("check", concepts, result))
308
+ except Exception:
309
+ logger.warning("Failed to persist trace for check()", exc_info=True)
310
+ return result
311
+
312
+ def learn_all(
313
+ self,
314
+ grounding: GroundingResult,
315
+ callback: LearningCallback,
316
+ concepts: list[str],
317
+ min_depth: DepthLevel | None = None,
318
+ ) -> GroundingResult:
319
+ """
320
+ Iteratively resolve all gaps via the learning loop.
321
+
322
+ Processes one gap at a time, re-grounding after each accepted/modified
323
+ candidate. Skipped gaps are excluded from subsequent rounds (the user
324
+ has acknowledged them). Rejected gaps stop the loop entirely.
325
+ Returns the final GroundingResult.
326
+ """
327
+ skipped: set[int] = set()
328
+ learning_outcomes: list[LearningResult] = []
329
+ self._suppress_trace = True
330
+ try:
331
+ with callback:
332
+ while not grounding.grounded and grounding.gaps:
333
+ gap_index = next(
334
+ (i for i, g in enumerate(grounding.gaps) if i not in skipped),
335
+ None,
336
+ )
337
+ if gap_index is None:
338
+ break
339
+ result = self._learning_engine.learn_at(grounding, gap_index, callback)
340
+ learning_outcomes.append(result)
341
+ self._update_learning_metric(grounding.gaps[gap_index], result.decision)
342
+ if result.decision == CandidateDecision.REJECTED:
343
+ break
344
+ if result.decision == CandidateDecision.SKIPPED:
345
+ skipped.add(gap_index)
346
+ continue
347
+ self._resolver.invalidate()
348
+ grounding = self.check(concepts, min_depth=min_depth)
349
+ skipped.clear()
350
+ finally:
351
+ self._suppress_trace = False
352
+
353
+ if self._trace_writer is not None and learning_outcomes:
354
+ try:
355
+ self._trace_writer.write(
356
+ build_trace_entry("learn", concepts, grounding, learning_outcomes)
357
+ )
358
+ except Exception:
359
+ logger.warning("Failed to persist trace for learn_all()", exc_info=True)
360
+
361
+ return grounding
362
+
363
+ def check_policy(
364
+ self,
365
+ concepts: list[str] | GroundingResult,
366
+ cardinality: str | None = None,
367
+ call_context: PolicyCallContext | None = None,
368
+ on_policy: Callable[[list[PolicyViolation]], bool] | None = None,
369
+ ) -> PolicyResult:
370
+ """
371
+ Evaluate policies for the given concepts.
372
+
373
+ `concepts` may be a list of concept names (grounding is run) or a
374
+ pre-computed `GroundingResult` (grounding is skipped).
375
+
376
+ `call_context` carries the tool name, grounding result, and the args/
377
+ kwargs of the decorated function so that policy callbacks can make
378
+ domain-specific decisions. Omit when calling outside a guarded context.
379
+
380
+ `on_policy` is an optional handler consulted when any violation has
381
+ `requires_confirmation=True`. It receives all violations and returns
382
+ a single bool — True to proceed, False to block. When absent, the
383
+ fail-safe is BLOCK.
384
+
385
+ Returns PolicyResult with action PASS or BLOCK (never PENDING).
386
+ """
387
+ if isinstance(concepts, GroundingResult):
388
+ grounding = concepts
389
+ else:
390
+ grounding = self._stamp_identity(self._engine.ground(concepts, self._resolver))
391
+
392
+ if grounding.trace is None:
393
+ return PolicyResult(action=PolicyAction.PASS)
394
+
395
+ card_enum: Cardinality | None = None
396
+ if cardinality is not None:
397
+ try:
398
+ card_enum = Cardinality(cardinality)
399
+ except ValueError:
400
+ card_enum = None # unknown → fire all policies
401
+
402
+ gate = PolicyGate()
403
+ violations = gate.evaluate(grounding.trace, card_enum, call_context)
404
+
405
+ if not violations:
406
+ policy_result = PolicyResult(action=PolicyAction.PASS)
407
+ else:
408
+ hard_blocks = [v for v in violations if not v.requires_confirmation]
409
+ pending = [v for v in violations if v.requires_confirmation]
410
+
411
+ # Hard blocks do not consult on_policy — they are immediate BLOCKs with their own messages
412
+ if hard_blocks:
413
+ messages = "; ".join(v.message for v in hard_blocks)
414
+ policy_result = PolicyResult(
415
+ action=PolicyAction.BLOCK,
416
+ reason=messages,
417
+ violations=violations,
418
+ )
419
+ else:
420
+ # Only confirmation-required violations remain — consult on_policy
421
+ if on_policy is not None:
422
+ if on_policy(pending):
423
+ policy_result = PolicyResult(
424
+ action=PolicyAction.PASS,
425
+ violations=pending,
426
+ )
427
+ else:
428
+ policy_result = PolicyResult(
429
+ action=PolicyAction.BLOCK,
430
+ reason="User declined",
431
+ violations=pending,
432
+ )
433
+ else:
434
+ policy_result = PolicyResult(
435
+ action=PolicyAction.BLOCK,
436
+ reason="Confirmation required, no handler",
437
+ violations=pending,
438
+ )
439
+
440
+ return policy_result
vre/core/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ # Copyright 2026 Andrew Greene
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ from vre.core.errors import (
5
+ CandidateValidationError,
6
+ CyclicRelationshipError,
7
+ GraphError,
8
+ GraphIntegrityError,
9
+ HydrationError,
10
+ PersistenceError,
11
+ ResolutionError,
12
+ VREError,
13
+ )
14
+ from vre.core.models import (
15
+ Provenance,
16
+ ProvenanceSource,
17
+ TRANSITIVE_RELATION_TYPES,
18
+ )
19
+
20
+ __all__ = [
21
+ "CandidateValidationError",
22
+ "CyclicRelationshipError",
23
+ "GraphError",
24
+ "GraphIntegrityError",
25
+ "HydrationError",
26
+ "PersistenceError",
27
+ "Provenance",
28
+ "ProvenanceSource",
29
+ "ResolutionError",
30
+ "TRANSITIVE_RELATION_TYPES",
31
+ "VREError",
32
+ ]
vre/core/errors.py ADDED
@@ -0,0 +1,49 @@
1
+ # Copyright 2026 Andrew Greene
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """
5
+ VRE exception hierarchy.
6
+
7
+ All VRE-specific exceptions derive from VREError so integrators can catch
8
+ at the desired granularity — from a single error type up to the entire
9
+ framework.
10
+
11
+ VRE's responsibility is to roll back any in-memory mutations and re-raise
12
+ errors with clear, typed exceptions. Integrators decide recovery strategy.
13
+ """
14
+
15
+
16
+ class VREError(Exception):
17
+ """Base exception for all VRE errors."""
18
+
19
+
20
+ class GraphError(VREError):
21
+ """A graph backend operation failed (read or write)."""
22
+
23
+
24
+ class PersistenceError(GraphError):
25
+ """A write operation against the graph backend failed."""
26
+
27
+
28
+ class GraphIntegrityError(VREError):
29
+ """A graph operation would violate structural integrity constraints."""
30
+
31
+
32
+ class CyclicRelationshipError(GraphIntegrityError):
33
+ """An edge would create a cycle on transitive relationship types."""
34
+
35
+
36
+ class HydrationError(VREError):
37
+ """Failed to reconstruct a domain object from stored data."""
38
+
39
+
40
+ class ResolutionError(VREError):
41
+ """Failed to resolve a concept name or identifier."""
42
+
43
+
44
+ class CandidateValidationError(VREError):
45
+ """A learning candidate is missing required fields or references invalid data."""
46
+
47
+
48
+ class RegistryError(VREError):
49
+ """A file-based registry operation failed (read, write, or corruption)."""