Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c | """M22 β Full turn-loop integration proofs (offline, always-run). | |
| The live-server acceptance harness lives in ``tests/test_turn_loop_integration.py`` (gated | |
| by ``LOOSECANVAS_RUN_LLM_INTEGRATION``). This module holds M22's **offline** automatable | |
| proofs so they run in CI without a model: | |
| * item 12 β one-directional import lint (core must not import extractors). | |
| * items 7/10/11 β the SAME turn loop, curator, validator, reducer and adapter succeed on | |
| BOTH the M04a wiki fixture and an M04b text-extracted graph, with no "is this a repo" | |
| branch. The model is mocked via ``httpx.MockTransport``. | |
| * item 13 β only the evidence service may transition ``support_state`` to | |
| ``source_supported``. | |
| * item 1 β the M23 repo leg (skipped unless ``LOOSECANVAS_TEST_REPO`` is set). | |
| """ | |
| from __future__ import annotations | |
| import importlib.util | |
| import json | |
| import os | |
| from collections.abc import Iterator | |
| from pathlib import Path | |
| from types import ModuleType | |
| from typing import Any | |
| import httpx | |
| import pytest | |
| from loosecanvas.claim_state_machine import InvalidTransitionError, transition_support | |
| from loosecanvas.config import ALLOWED_BASE_DIR | |
| from loosecanvas.contracts import ( | |
| Claim, | |
| EvidenceRef, | |
| Node, | |
| SourceFile, | |
| SourceSnapshot, | |
| ) | |
| from loosecanvas.evidence_service import EvidenceService, compute_snippet_hash | |
| from loosecanvas.extraction_pipeline import ExtractionOrchestrator, ExtractionResult | |
| from loosecanvas.extractors.text_graph_adapter import extract_graph_from_text | |
| from loosecanvas.graph_repository import LooseGraphRepository | |
| from loosecanvas.llm_client import LLMClient | |
| from loosecanvas.scene_curator import SceneCurator | |
| from loosecanvas.sceneplan_generator import ScenePlanGenerator | |
| from loosecanvas.turn_logic import ( | |
| TurnResult, | |
| load_fixture_session, | |
| run_turn, | |
| session_locks, | |
| session_store, | |
| ) | |
| _REPO_ROOT = Path(__file__).resolve().parents[1] | |
| _SMALL_GRAPH = _REPO_ROOT / "fixtures" / "small_graph.json" | |
| _MAX_RENDERER_OPS = 50 | |
| # ββ Fixtures / helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _isolate_sessions() -> Iterator[None]: | |
| """Keep the module-level session store from leaking between tests.""" | |
| session_store.clear() | |
| session_locks.clear() | |
| yield | |
| session_store.clear() | |
| session_locks.clear() | |
| def _mock_client(content: str) -> LLMClient: | |
| """An ``LLMClient`` whose every completion returns ``content`` verbatim.""" | |
| def handler(_request: httpx.Request) -> httpx.Response: | |
| return httpx.Response( | |
| 200, json={"choices": [{"message": {"content": content}}]} | |
| ) | |
| return LLMClient(base_url="http://test", transport=httpx.MockTransport(handler)) | |
| def _sceneplan_json(target_id: str) -> str: | |
| """A schema-valid ``ScenePlan`` targeting a real visible node (passes M14).""" | |
| return json.dumps( | |
| { | |
| "actions": [ | |
| { | |
| "type": "center_on", | |
| "target_id": target_id, | |
| "reasoning": "Focus the user's question on the selected concept.", | |
| }, | |
| { | |
| "type": "highlight", | |
| "target_id": target_id, | |
| "reasoning": "Mark the node of interest so it stands out.", | |
| }, | |
| ], | |
| "summary": "Centering on the selected concept so its role is visible.", | |
| } | |
| ) | |
| def _load_lint_module() -> ModuleType: | |
| """Import ``scripts/lint_import_dependencies.py`` by path (it is not a package).""" | |
| path = _REPO_ROOT / "scripts" / "lint_import_dependencies.py" | |
| spec = importlib.util.spec_from_file_location("lint_import_dependencies", path) | |
| assert spec is not None and spec.loader is not None | |
| module = importlib.util.module_from_spec(spec) | |
| spec.loader.exec_module(module) | |
| return module | |
| def _assert_successful_turn(result: TurnResult) -> None: | |
| """Shared post-conditions for a substrate-agnostic happy-path turn (items 7/10).""" | |
| assert result.status != "failed", result.assistant_message | |
| assert result.scene_patch is not None | |
| patch = result.scene_patch | |
| # Non-empty ScenePatch: the highlight/center actions must have moved scene state. | |
| assert patch.set_highlighted_ids or patch.set_camera_target_id is not None | |
| operations = result.renderer_patch.operations | |
| assert operations, "renderer patch carried no operations" | |
| assert len(operations) < _MAX_RENDERER_OPS | |
| # ββ item 12: import lint βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_import_lint_core_does_not_import_extractors() -> None: | |
| lint = _load_lint_module() | |
| violations = lint.find_forbidden_imports() | |
| assert violations == [], "\n".join(violations) | |
| # ββ items 7/10/11: substrate-agnostic turn loop ββββββββββββββββββββββββββββββββ | |
| async def test_turn_loop_on_m04a_wiki_fixture() -> None: | |
| session_id, _snapshot = await load_fixture_session(fixture_path=_SMALL_GRAPH) | |
| record = session_store[session_id] | |
| target = record.scene.visible_node_ids[0] | |
| generator = ScenePlanGenerator(_mock_client(_sceneplan_json(target))) | |
| result = await run_turn( | |
| session_id, | |
| {"kind": "node", "id": target}, | |
| "explain this concept's role", | |
| generator, | |
| ) | |
| _assert_successful_turn(result) | |
| async def test_turn_loop_on_m04b_text_extracted_graph() -> None: | |
| # An M04b text-extracted graph must flow through the IDENTICAL turn loop as the | |
| # repo/wiki leg β no code path may branch on "is this a repo" (guardrail 8). | |
| article = ( | |
| "# Gradient Descent\n\n" | |
| "Gradient descent is an optimization method. " | |
| "A loss function measures prediction error." | |
| ) | |
| concept_json = json.dumps( | |
| { | |
| "concept_nodes": [ | |
| { | |
| "id": "concept::gradient_descent", | |
| "label": "Gradient Descent", | |
| "summary": "An iterative optimization method.", | |
| "start_char": 2, | |
| "end_char": 18, | |
| }, | |
| { | |
| "id": "concept::loss_function", | |
| "label": "Loss Function", | |
| "summary": "Measures prediction error.", | |
| "start_char": 58, | |
| "end_char": 71, | |
| }, | |
| ], | |
| "semantic_edges": [ | |
| { | |
| "source_id": "concept::gradient_descent", | |
| "target_id": "concept::loss_function", | |
| "type": "related_to", | |
| "label": "minimizes", | |
| } | |
| ], | |
| } | |
| ) | |
| graph = await extract_graph_from_text( | |
| article, "Gradient Descent", _mock_client(concept_json) | |
| ) | |
| assert graph.nodes, "M04b adapter produced no nodes" | |
| # Same curator (M12) as the repo leg builds the initial scene. | |
| repo = LooseGraphRepository.from_extraction( | |
| graph.nodes, graph.edges, graph.claims, SourceSnapshot() | |
| ) | |
| scene, _pathways = SceneCurator(repo).curate_initial_scene() | |
| assert scene.visible_node_ids, "curator produced no visible nodes" | |
| fixture_data: dict[str, Any] = { | |
| "graph": graph.model_dump(by_alias=True), | |
| "scene": scene.model_dump(), | |
| } | |
| session_id, _snapshot = await load_fixture_session(fixture_data=fixture_data) | |
| record = session_store[session_id] | |
| target = record.scene.visible_node_ids[0] | |
| generator = ScenePlanGenerator(_mock_client(_sceneplan_json(target))) | |
| result = await run_turn( | |
| session_id, | |
| {"kind": "node", "id": target}, | |
| "explain this concept's role", | |
| generator, | |
| ) | |
| _assert_successful_turn(result) | |
| # ββ item 13: evidence-service actor gate βββββββββββββββββββββββββββββββββββββββ | |
| async def test_only_evidence_service_can_corroborate() -> None: | |
| content = "intro line\nGradient descent minimizes the loss.\ntrailing line" | |
| snippet = "\n".join(content.splitlines()[1:2]) # line 2, the evidence span | |
| snapshot = SourceSnapshot( | |
| snapshot_id="snap1", | |
| root_path="", | |
| files={ | |
| "src1": SourceFile( | |
| source_id="src1", | |
| path="doc.md", | |
| content=content, | |
| line_count=len(content.splitlines()), | |
| ) | |
| }, | |
| ) | |
| ref = EvidenceRef( | |
| source_id="src1", | |
| node_id="n1", | |
| start_line=2, | |
| end_line=2, | |
| snippet_hash=compute_snippet_hash(snippet), | |
| ) | |
| node = Node(id="n1", kind="concept", label="Gradient Descent", evidence_refs=[ref]) | |
| claim = Claim( | |
| id="cl1", | |
| claim_type="note", | |
| target_id="n1", | |
| text="Gradient descent minimizes the loss.", | |
| origin="model_inferred", | |
| support_state="unverified", | |
| review_state="pending", | |
| evidence_refs=[ref], | |
| ) | |
| repo = LooseGraphRepository.from_extraction([node], [], [claim], snapshot) | |
| # A non-evidence actor may NOT promote the claim to source_supported. | |
| with pytest.raises(InvalidTransitionError): | |
| transition_support(claim, "source_supported", actor="not_evidence_service") | |
| # The evidence service (correct actor) corroborates the fresh-evidence claim. | |
| service = EvidenceService(snapshot) | |
| updated = await service.corroborate_claims("n1", repo) | |
| assert any(c.support_state == "source_supported" for c in updated) | |
| assert repo.claims["cl1"].support_state == "source_supported" | |
| # Trust model stays unflattened: origin is unchanged and still immutable. | |
| assert repo.claims["cl1"].origin == "model_inferred" | |
| # ββ item 1: repo leg via M23 (skipped unless a repo is provided) βββββββββββββββ | |
| async def test_repo_leg_via_m23_extraction() -> None: | |
| repo_env = os.environ.get("LOOSECANVAS_TEST_REPO") | |
| if not repo_env: | |
| pytest.skip( | |
| "set LOOSECANVAS_TEST_REPO to a repo under ALLOWED_BASE_DIR to run the " | |
| "repo-leg integration test" | |
| ) | |
| repo_path = Path(repo_env).resolve() | |
| assert repo_path.is_relative_to( | |
| ALLOWED_BASE_DIR | |
| ), f"LOOSECANVAS_TEST_REPO must be under ALLOWED_BASE_DIR ({ALLOWED_BASE_DIR})" | |
| result = await ExtractionOrchestrator().run(repo_path) | |
| assert isinstance(result, ExtractionResult) | |
| assert result.scene.visible_node_ids, "extraction produced an empty scene" | |