Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"name": "graqle",
"source": "./plugins/codex/graqle",
"description": "Graph-powered codebase reasoning, impact analysis, and governed edits via the GraQle MCP server, plus governed-workflow skills.",
"version": "0.84.0",
"version": "0.84.1",
"author": {
"name": "Quantamix Solutions",
"url": "https://graqle.com"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"name": "graqle",
"source": "./plugins/claude-code/graqle",
"description": "Graph-powered codebase reasoning, impact analysis, and governed edits via the GraQle MCP server, plus governed-workflow skills and an optional governance gate hook.",
"version": "0.84.0",
"version": "0.84.1",
"author": {
"name": "Quantamix Solutions",
"url": "https://graqle.com"
Expand Down
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# CR-012 PR-012c: the AC-9 golden fixture is byte-hash-asserted by
# tests/test_core/test_governance_threshold_resolver.py. With core.autocrlf=true
# a Windows checkout rewrites it to CRLF and the recorded sha256 no longer
# matches, so the test fails on a fresh clone for no behavioural reason.
# Pin it to LF everywhere.
tests/fixtures/*.json -text eol=lf
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ jobs:
ruff check --fix graqle/ tests/ || true
echo "Lint complete (warnings reported, not blocking)"

# CR-012 PR-012b (AC-21): the DAG dependency-direction contract.
# `graqle.assurance` may import `graqle.governance`; never the reverse.
# BLOCKING on purpose — a violation here is the failure the duplicated
# `GateVerdictRef` shape exists to prevent.
- name: Import-linter contracts
run: lint-imports

- name: Run tests with coverage
run: |
# 26 test files reference classes from stub files emptied in a prior
Expand Down
29 changes: 29 additions & 0 deletions .importlinter
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# CR-012 AC-21 — dependency direction for the DAG-2026 assurance layer.
#
# `graqle.assurance` may import `graqle.governance`; the reverse is forbidden.
# This is why `GateVerdictRef` is duplicated in `governance/trace_schema.py`
# with a str-typed `outcome` instead of importing the `GateOutcome` enum
# (CR-012 §4.4, OQ-3). A parity test keeps the two shapes field-identical.
#
# Run locally: lint-imports
[importlinter]
root_package = graqle

[importlinter:contract:governance-not-import-assurance]
name = graqle.governance must never import graqle.assurance
type = forbidden
source_modules =
graqle.governance
forbidden_modules =
graqle.assurance
# Ruling N5: `config/settings.py` reaches `graqle.assurance` ONLY from inside
# function/property bodies (settings.py:812 AssuranceConfig.enabled,
# :1188 _reject_yaml_derived, :1202 _validate_dag_flag), and
# `governance.kg_write_gate` imports `config.settings` only under
# TYPE_CHECKING. import-linter's static graph counts those lazy edges, but at
# RUNTIME importing graqle.governance loads no graqle.assurance module — which
# is the invariant N5 actually specifies and what the CR's import-graph tests
# assert. `tests/test_assurance/test_import_direction.py` proves it at runtime;
# this contract catches any EAGER, module-level import.
ignore_imports =
graqle.config.settings -> graqle.assurance.settings
39 changes: 38 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ All notable changes to GraQle are documented in this file.

---

## Unreleased — 0.84.0 (DAG-2026 CR-012 foundation)
## Unreleased — 0.84.1 (DAG-2026 CR-012 foundation)

> **CI suppression disclosure (CR-CI-001, issue #350).** CI's green signal for this release is
> maintained by **37 `--ignore=` flags** in `.github/workflows/ci.yml`: those test files are not
> executed by CI. **16 test files fail on a local developer machine**, all pre-existing and
> predating the DAG-2026 programme. This release introduces **zero** regressions — the failure
> set is identical on this branch and on the unmodified base. Remediation is tracked as
> **CR-CI-001 (issue #350)**, which **blocks v0.85.0**. One defect that the suppression had
> hidden — `_FakeServer` doubles broken by PR-012c — is fixed in this release.

> Feature-flagged foundation for the Decision Assurance Gate programme (ADR-RT-004).
> `GRAQLE_DAG_ENABLED` is **off by default** and, while off, changes no SDK behaviour:
Expand All @@ -24,6 +32,35 @@ All notable changes to GraQle are documented in this file.
- **`docs/dag/ground-truth-addendum.md`** — the binding correction of the research charter's description
of 0.83.0 gating, with the SDK team's line-anchor verification notes.

### Added (CR-012 / PR-012b — gate vocabulary)

- **`graqle.assurance.outcomes`** — `GateOutcome`, the five outcomes a gate evaluation can return,
with `OUTCOME_SEVERITY` ordering and `worst()` / `worst_terminal()` reducers. `GateOutcome` is
private to `graqle.assurance` and is deliberately *not* an extension of
`graqle.governance.trace_schema.Decision`: `Decision` records what a governance gate did,
`GateOutcome` records what the caller must do next.
- **`graqle.assurance.reason_codes`** — a validated registry of machine-readable reason codes
(`DAG-<class>-<slug>`), `Severity`, `validate()`, `max_severity()` and `resolvable()`.
Codes are registered through the registry, never matched ad hoc by a local regex.
- **`graqle.assurance.verdict`** — `GateVerdict`, `GateVerdictRef`, `HardGateResultRef` and
`DeterminismRecord`, at `VERDICT_SCHEMA_VERSION = "1"`.
- **Trace schema v3** — the trace carries the gate outcome as a plain string via `GateVerdictRef`,
preserving the rule that `graqle.governance` never imports `graqle.assurance` (CR-012 AC-21),
which is enforced by a contract test in CI.

### Added (CR-012 / PR-012d — fail-visible activation)

- **Typed activation failures** — `VectorIndexMissingError` and `EmbeddingDimensionMismatchError`
replace silent degradation when a Neo4j vector index is absent or an embedding width disagrees.
Activation now fails visibly instead of quietly returning weaker results.
- **`graq doctor`** reports Neo4j activation readiness, so the condition is diagnosable before a run.

### Changed (CR-012 / PR-012c — hygiene)

- **`graqle/core/governance_thresholds.py`** centralises governance threshold resolution behind
`resolve_governance_config()`, replacing scattered per-call-site defaults.
- Reliability-diagram and Neo4j connector fixes; MCP dev-server tool registration tidied.

### Behaviour change when the flag is ON (opt-in only)

- `GraqleConfig.from_yaml()` now fails closed with `ConfigurationError` if `GRAQLE_DAG_ENABLED` is on and
Expand Down
2 changes: 1 addition & 1 deletion graqle/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
# constraints: none
# ── /graqle:intelligence ──

__version__ = "0.84.0"
__version__ = "0.84.1"
158 changes: 134 additions & 24 deletions graqle/activation/cypher_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,16 @@
from __future__ import annotations

import logging
import time
from typing import Any

logger = logging.getLogger("graqle.activation.cypher")

#: TTL for the diagnostic vector-index probe (sentinel M1). Short enough that
#: an operator fixing the index sees the change almost immediately; long enough
#: that a retry storm cannot amplify load on an already-failing substrate.
_PROBE_TTL_SECONDS = 30.0


class CypherActivation:
"""Activate subgraph nodes via Neo4j chunk-level vector search.
Expand All @@ -56,6 +62,7 @@ def __init__(
embedding_engine: Any,
max_nodes: int = 50,
k_chunks: int = 100,
strict: bool = False,
) -> None:
"""
Args:
Expand All @@ -64,50 +71,153 @@ def __init__(
max_nodes: Maximum number of nodes to activate.
k_chunks: Number of chunks to retrieve from vector index (more chunks
means better coverage but slower).
strict: CR-012 PR-012d (ruling N7). When True, a substrate that
cannot activate raises
:class:`~graqle.core.exceptions.VectorIndexMissingError` instead
of degrading to the full graph. Default False preserves
v0.83.0 behaviour except that the degraded path now logs at
ERROR and is reported via :attr:`activation_mode`.
"""
self._connector = connector
self._embedding_engine = embedding_engine
self._max_nodes = max_nodes
self._k_chunks = k_chunks
self._strict = strict
self.last_relevance: dict[str, float] = {}
#: What actually executed on the last ``activate`` call — NOT what was
#: configured. ``"semantic"`` is the healthy vector path;
#: ``"keyword_fallback"`` means the whole graph was returned because
#: activation could not run. Read by the graph-health probe, which
#: treats ``keyword_fallback`` as degraded.
self.activation_mode: str = "semantic"
#: Chunks with no embedding at the last degradation, when known.
self.chunks_unembedded: int = 0
#: Cached ``probe_vector_index()`` result + monotonic timestamp.
self._probe_cache: tuple[float, dict[str, Any]] | None = None

def _probe_cached(self) -> dict[str, Any]:
"""Diagnostic probe with a short TTL.

Sentinel M1: ``_degrade`` runs on a path that is ALREADY failing. Probing
the substrate on every call amplifies load on the degraded resource at
the worst possible moment — a retry loop or concurrent burst would issue
one live round-trip per failure. The result is diagnostic metadata, so a
few seconds of staleness costs nothing.

Never raises: diagnosis must not become a second failure mode.
"""
now = time.monotonic()
if self._probe_cache is not None and (now - self._probe_cache[0]) < _PROBE_TTL_SECONDS:
return self._probe_cache[1]

probe: dict[str, Any] = {}
try:
probe_fn = getattr(self._connector, "probe_vector_index", None)
if callable(probe_fn):
probe = probe_fn() or {}
except Exception: # noqa: BLE001 — diagnosis must never mask the defect
probe = {}

self._probe_cache = (now, probe)
return probe

def _degrade(self, graph: Any, reason: str, exc: Exception | None = None) -> list[str]:
"""Fall back to the full graph, loudly.

Every degraded activation logs at ERROR and is visible in
``graph_health`` — the silent-WARNING path this replaces is the defect
ruling N7 was raised against. When ``strict`` is set the caller gets a
typed error instead of a quietly wrong answer.
"""
probe = self._probe_cached()

total = probe.get("chunks_total")
embedded = probe.get("chunks_embedded")
self.chunks_unembedded = (
max(0, int(total) - int(embedded))
if isinstance(total, int) and isinstance(embedded, int)
else 0
)
self.activation_mode = "keyword_fallback"

# Sentinel B1: log LEVEL is an observable contract that alerting keys
# on. The default (non-strict) path keeps v0.83.0's WARNING so a
# graceful fallback does not start paging operators; only an opt-in
# strict caller — who has asked to treat this as fatal — gets ERROR.
# The enriched CONTENT (index state + coverage) lands on both paths,
# which is the "log fields" improvement ruling N7 asked for.
logger.log(
logging.ERROR if self._strict else logging.WARNING,
"CypherActivation DEGRADED (%s): returning the full graph, so this "
"result is NOT retrieval-grounded. vector index %r state=%s "
"chunk embedding coverage=%s/%s%s",
reason,
probe.get("index_name", "?"),
probe.get("index_state", "?"),
embedded if embedded is not None else "?",
total if total is not None else "?",
f" ({type(exc).__name__}: {exc})" if exc is not None else "",
)

if self._strict:
from graqle.core.exceptions import VectorIndexMissingError

raise VectorIndexMissingError(
index_name=str(probe.get("index_name", "unknown")),
database=probe.get("database"),
index_state=probe.get("index_state"),
chunks_total=total,
chunks_embedded=embedded,
) from exc

self.last_relevance = {nid: 1.0 for nid in graph.nodes}
return list(graph.nodes.keys())[:self._max_nodes]

def activate(
self,
graph: Any,
query: str,
strict: bool | None = None,
) -> list[str]:
"""Activate nodes by vector search on chunk embeddings.

Side effect: stores relevance scores in ``self.last_relevance``
for use in confidence calibration (Bug 18 fix).
for use in confidence calibration (Bug 18 fix), and records what
actually ran in ``self.activation_mode``.

Args:
strict: per-call override of the constructor's ``strict``. When
True, an unusable substrate raises ``VectorIndexMissingError``.

Returns:
List of activated node IDs present in the graph.
"""
# 1. Embed the query
try:
query_embedding = self._embedding_engine.embed(query)
except Exception as exc:
logger.warning("CypherActivation: embedding failed (%s), falling back to full", exc)
self.last_relevance = {nid: 1.0 for nid in graph.nodes}
return list(graph.nodes.keys())[:self._max_nodes]

# 2. Vector search → (node_id, relevance) pairs
previous_strict = self._strict
if strict is not None:
self._strict = strict
try:
hits = self._connector.vector_search(
query_embedding=query_embedding,
k=self._k_chunks,
max_nodes=self._max_nodes,
)
except Exception as exc:
logger.warning("CypherActivation: vector search failed (%s), falling back to full", exc)
self.last_relevance = {nid: 1.0 for nid in graph.nodes}
return list(graph.nodes.keys())[:self._max_nodes]

if not hits:
logger.warning("CypherActivation: vector search returned 0 hits, using full graph")
self.last_relevance = {nid: 1.0 for nid in graph.nodes}
return list(graph.nodes.keys())[:self._max_nodes]
# 1. Embed the query
try:
query_embedding = self._embedding_engine.embed(query)
except Exception as exc:
return self._degrade(graph, "query embedding failed", exc)

# 2. Vector search → (node_id, relevance) pairs
try:
hits = self._connector.vector_search(
query_embedding=query_embedding,
k=self._k_chunks,
max_nodes=self._max_nodes,
)
except Exception as exc:
return self._degrade(graph, "vector search failed", exc)

if not hits:
return self._degrade(graph, "vector search returned 0 hits")
finally:
self._strict = previous_strict

self.activation_mode = "semantic"

# 3. Filter to nodes that exist in the in-memory graph
activated = []
Expand Down
6 changes: 6 additions & 0 deletions graqle/activation/factory_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,16 @@ def _make_cypher_activation(graph: Any) -> Any:
)
emb_engine = None

# CR-012 PR-012d (ruling N7): `strict` is plumbed from config so the kwarg
# is reachable in production, not just from tests. Absent config keeps the
# v0.83.0 default (False) — degrade, but loudly and visibly.
strict = bool(getattr(cfg.activation, "strict", False))

return CypherActivation(
connector=graph._neo4j_connector,
embedding_engine=emb_engine,
max_nodes=cfg.activation.max_nodes,
strict=strict,
)


Expand Down
Loading
Loading