Compare commits

..

1 Commits

Author SHA1 Message Date
phernandez a026412a13 docs: capture graph intelligence phase status and plan
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-05 10:00:49 -06:00
27 changed files with 1937 additions and 2213 deletions
@@ -0,0 +1,594 @@
# SPEC-LOCAL-GRAPH-INTELLIGENCE-IMPLEMENTATION-PLAN
**Status:** Draft (Decision-Complete)
**Date:** 2026-03-05
**Owner:** Basic Memory Engineering
**Implementation Status (2026-03-05):** Phase 1 contract skeleton implemented in `basic-memory` branch `codex/graph-intelligence-phase1`.
**Related Specs:**
1. `/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE-MASTER.md`
2. `/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE-TECHNICAL-ADDENDUM.md`
3. `/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE.md`
## Scope and Intent
This document is the execution handoff for Local+ Graph Intelligence.
It defines exactly how we will deliver graph and FCM capabilities inside the existing Basic Memory architecture:
1. FastAPI-first business logic.
2. MCP and CLI as thin facades.
3. Local/cloud contract parity.
4. Tight build-test-iterate loop for fast delivery.
This file is intentionally implementation-oriented and does not duplicate pricing narrative from the master spec.
## Architecture Alignment (FastAPI-first, MCP/CLI facade)
Locked architecture alignment for implementation:
1. MCP tools remain thin proxy facades.
2. CLI `bm tool` commands call MCP tools in JSON mode.
3. Core logic lives in FastAPI routers and services.
4. Cloud and local share the same REST contracts.
5. Per-project routing continues through existing project client patterns.
Execution mapping:
1. API routers define public contracts in `/graph` and `/fcm` domains.
2. Services own traversal, scoring, simulation, and fallback logic.
3. Repositories and index providers own data access and graph index operations.
4. MCP typed clients call REST endpoints and return JSON-first tool output.
5. CLI passthrough executes tool calls and prints machine-friendly JSON.
## Locked Decisions
1. SQLite remains operational source for entities, relations, embeddings, and project state.
2. Markdown remains source of truth.
3. Oxigraph/pyoxigraph is the derived graph index for deep traversal.
4. FCM simulation runs in Python service layer; it is not delegated to graph DB query engines.
5. Graph index is rebuildable and disposable; stale index never blocks user workflows.
6. FCM model and scenario artifacts persist in app database.
7. Local+ features are gated by config flags first; entitlement wiring follows later.
8. Graph-first vertical slices ship before deep FCM expansion.
9. Atomic tools ship first; orchestration workflows are deferred.
10. Existing `build_context` and `search_notes` remain backward compatible with no breaking change.
## Progress Snapshot (as of 2026-03-05)
Completed in Phase 1:
1. Added `/graph` and `/fcm` v2 routers with all required contract endpoints.
2. Added graph/FCM request and response schemas for all public API contracts.
3. Added service-layer implementations for graph and FCM contract endpoints.
4. Added typed MCP clients for graph and FCM API calls.
5. Added MCP tools: `graph_lineage`, `graph_impact`, `graph_health`, `graph_reindex`, `fcm_simulate`, `fcm_rank_actions`, `fcm_import_model`, `fcm_export_model`.
6. Added CLI passthrough commands under `bm tool ...` for all planned graph/FCM operations.
7. Added scheduler task names for graph lifecycle: `sync_graph_entity`, `sync_graph_project`, `reindex_graph_project`.
8. Added focused tests for API, MCP clients/tools, and CLI graph/FCM passthrough.
9. Added fast-loop `just` targets: `test-graph-intel-api`, `test-graph-intel-mcp`, `test-graph-intel-cli`, `test-graph-intel`.
Validation completed:
1. `just test-graph-intel` passes.
2. `ruff check` passes on changed files.
3. `pyright` passes on changed files.
Still pending after Phase 1:
1. SQL-backed traversal/scoring for graph `lineage`, `impact`, and `health`.
2. Oxigraph provider integration and stale-index catch-up flow.
3. Persistent FCM model/scenario state and interop round-trip guarantees.
4. Config-flag and entitlement gating at API/tool boundaries.
5. Performance instrumentation and p95 envelope enforcement.
## Delivery Phases
### Phase 1: Contract skeleton
Status: Completed (2026-03-05)
Deliverables:
1. Add `/graph` and `/fcm` API routers with request/response schemas.
2. Add typed MCP clients for graph and FCM endpoints.
3. Add MCP tool passthrough commands for all new operations.
4. Add CLI `bm tool` passthrough commands mirroring MCP surface.
5. Add minimal smoke tests for route reachability and schema validation.
Exit criteria:
1. All endpoints return structured success and error envelopes.
2. MCP/CLI paths execute end-to-end with stubbed service responses.
### Phase 2: Graph capabilities on SQL-backed logic
Status: Next active phase
Deliverables:
1. Implement `lineage`, `impact`, and `health` in service layer using SQL-backed traversal and scoring.
2. Add provenance/evidence linking in graph outputs.
3. Add deterministic graph-health calculations for fixed snapshots.
Exit criteria:
1. `graph_lineage`, `graph_impact`, and `graph_health` pass contract tests.
2. SQL fallback behavior is explicit and covered by tests.
### Phase 3: Oxigraph derived index provider
Status: Planned
Deliverables:
1. Introduce Oxigraph provider behind graph-query interface.
2. Add lazy catch-up jobs and project-wide reindex operation.
3. Preserve SQL fallback when index is missing or stale.
Exit criteria:
1. Stale index path serves results via SQL and schedules catch-up.
2. Index rebuild can be triggered and completed without data loss.
### Phase 4: FCM import/simulate/rank/export
Status: Planned (contract endpoints complete, full behavior pending)
Deliverables:
1. Implement CSV-first import/export contracts.
2. Implement deterministic simulation core with convergence metadata.
3. Implement action ranking with evidence references and confidence output.
4. Persist scenario inputs and result artifacts.
Exit criteria:
1. Research flow scenario passes: import -> simulate -> rank -> export.
2. Interop round-trip preserves node/edge counts and signed weights.
### Phase 5: Hardening
Status: Planned
Deliverables:
1. Performance tuning against published latency envelopes.
2. Local/cloud parity tests for semantics and error behavior.
3. MCP prompt/docs updates for new graph and FCM tools.
4. Operational docs for reindex, fallback, and troubleshooting.
Exit criteria:
1. `just check` passes before merge.
2. Acceptance criteria in this document are fully met.
## API and Interface Additions
### Shared API conventions
1. All endpoints are project-scoped under `/v2/projects/{project_id}`.
2. Request and response bodies are JSON-first and agent-friendly.
3. Success envelope is endpoint-specific payload with deterministic fields and optional probabilistic fields.
4. Error envelope:
```json
{
"error": {
"code": "INVALID_ARGUMENT|NOT_FOUND|INDEX_NOT_READY|MODEL_INVALID|RESOURCE_LIMIT_EXCEEDED|INTERNAL_ERROR",
"message": "string",
"details": {}
}
}
```
5. Latency and scale targets are p95 targets for local default hardware profile.
### 1) `POST /v2/projects/{project_id}/graph/lineage`
Purpose: explain decision lineage and supporting evidence paths.
Request schema:
```json
{
"start": "string",
"goal": "string|null",
"max_hops": 4,
"relation_filters": ["string"]
}
```
Response schema:
```json
{
"root": {"id": "string", "title": "string", "permalink": "string"},
"paths": [
{
"path_id": "string",
"nodes": [{"id": "string", "title": "string"}],
"edges": [{"relation": "string", "direction": "outgoing|incoming"}],
"deterministic_path_score": 0.0,
"confidence": 0.0,
"evidence_refs": ["memory://..."]
}
],
"generated_at": "RFC3339"
}
```
Deterministic fields: `root`, `paths.nodes`, `paths.edges`, `deterministic_path_score`, `generated_at`.
Probabilistic fields: `confidence`.
Latency target: p95 <= 450ms with `max_hops<=4`.
Scale envelope: up to 50k nodes and 300k edges.
### 2) `POST /v2/projects/{project_id}/graph/impact`
Purpose: preview impact radius before edits or decisions.
Request schema:
```json
{
"target": "string",
"horizon": 2,
"relation_filters": ["string"],
"include_reasons": true
}
```
Response schema:
```json
{
"target": {"id": "string", "title": "string"},
"affected": [
{
"id": "string",
"title": "string",
"distance": 1,
"impact_score": 0.0,
"confidence": 0.0,
"reasons": ["string"],
"evidence_refs": ["memory://..."]
}
],
"summary": {"total_considered": 0, "total_returned": 0}
}
```
Deterministic fields: membership, distance, summary counts.
Probabilistic fields: `impact_score`, `confidence`.
Latency target: p95 <= 650ms for `horizon<=3`.
Scale envelope: default 200 results, hard cap 1000 with pagination token.
### 3) `GET /v2/projects/{project_id}/graph/health`
Purpose: report deterministic graph quality and actionable issues.
Query params:
1. `scope` optional directory prefix.
2. `timeframe` optional window like `30d`.
Response schema:
```json
{
"metrics": {
"orphan_rate": 0.0,
"stale_central_nodes": 0,
"overloaded_hubs": 0,
"contradiction_candidates": 0
},
"issues": [
{
"issue_type": "orphan|stale_central|overloaded_hub|contradiction_candidate",
"entity_id": "string",
"severity": "low|medium|high",
"reason": "string",
"suggested_action": "string",
"confidence": 0.0
}
],
"computed_at": "RFC3339"
}
```
Deterministic fields: `metrics`, issue membership for fixed snapshot.
Probabilistic fields: contradiction confidence when applicable.
Latency target: p95 <= 1500ms project-wide, <= 700ms scoped.
### 4) `POST /v2/projects/{project_id}/graph/reindex`
Purpose: force project-wide graph index rebuild.
Request schema:
```json
{
"mode": "full|incremental",
"reason": "string|null"
}
```
Response schema:
```json
{
"job_id": "string",
"status": "queued|running|completed|failed",
"scheduled_at": "RFC3339"
}
```
Deterministic fields: job metadata and status transitions.
Probabilistic fields: none.
Latency target: enqueue response p95 <= 120ms.
### 5) `POST /v2/projects/{project_id}/fcm/simulate`
Purpose: run FCM scenario simulation.
Request schema:
```json
{
"actions": [{"node_id": "string", "delta": 0.2}],
"scenario": {
"steps": 12,
"activation": "tanh|sigmoid|bounded_linear",
"decay": 0.05
},
"clamp_rules": [{"node_id": "string", "min": -1.0, "max": 1.0}]
}
```
Response schema:
```json
{
"baseline": [{"node_id": "string", "state": 0.0}],
"projected": [{"node_id": "string", "state": 0.0}],
"deltas": [{"node_id": "string", "delta": 0.0}],
"stability": {"converged": true, "iterations_used": 0, "residual": 0.0},
"confidence": 0.0,
"explanations": [{"node_id": "string", "top_influencers": [{"source": "string", "weight": 0.0}]}],
"evidence_refs": ["memory://..."]
}
```
Deterministic fields: baseline, projected, deltas, stability for fixed model and params.
Probabilistic fields: confidence.
Latency target: p95 <= 1000ms for <=500 nodes and <=5000 edges.
### 6) `POST /v2/projects/{project_id}/fcm/rank-actions`
Purpose: rank candidate interventions by expected outcome and risk.
Request schema:
```json
{
"goal": "string",
"constraints": {
"max_negative_impact": 0.25,
"required_tags": ["string"],
"disallowed_nodes": ["string"]
},
"top_k": 10
}
```
Response schema:
```json
{
"goal": {"node_id": "string", "label": "string"},
"recommendations": [
{
"action_node_id": "string",
"expected_goal_delta": 0.0,
"risk_penalty": 0.0,
"net_score": 0.0,
"confidence": 0.0,
"rationale": ["string"],
"evidence_refs": ["memory://..."]
}
]
}
```
Deterministic fields: candidate set and constraint compliance.
Probabilistic fields: expected delta, penalty, net score, confidence.
Latency target: p95 <= 1500ms for top-10 from <=100 candidates.
### 7) `POST /v2/projects/{project_id}/fcm/import`
Purpose: import FCM model from CSV-first contract.
Request schema:
```json
{
"source": "string",
"format": "csv_bundle_v1",
"merge_mode": "replace|upsert"
}
```
Response schema:
```json
{
"import_id": "string",
"nodes_loaded": 0,
"edges_loaded": 0,
"warnings": ["string"],
"errors": ["string"]
}
```
Deterministic fields: counts and validation diagnostics.
Probabilistic fields: none.
Latency target: p95 <= 2500ms for 10k edges import.
### 8) `POST /v2/projects/{project_id}/fcm/export`
Purpose: export FCM model for interoperability.
Request schema:
```json
{
"format": "csv_bundle_v1",
"selection": {
"scope": "all|tag|subgraph",
"tag": "string|null",
"seed_nodes": ["string"]
}
}
```
Response schema:
```json
{
"export_id": "string",
"format": "csv_bundle_v1",
"files": [{"name": "nodes.csv", "path": "string"}, {"name": "edges.csv", "path": "string"}],
"node_count": 0,
"edge_count": 0
}
```
Deterministic fields: file names and counts for fixed selection.
Probabilistic fields: none.
Latency target: p95 <= 1800ms for 50k edges export.
## Data Model and Storage Boundaries
1. SQLite is mandatory operational source for entities, relations, embeddings, and project metadata.
2. Oxigraph stores derived knowledge graph index only.
3. FCM state persists in app database with scenario artifacts and run history.
4. Graph index is rebuildable and disposable by design.
5. Markdown files remain canonical source of truth.
Implementation data boundaries:
1. Knowledge graph schema tracks descriptive nodes and typed edges plus provenance.
2. FCM schema tracks signed weighted causal edges and node states.
3. Provenance model requires `evidence_refs`, `confidence`, and `updated_at`.
4. Scenario model stores interventions, constraints, run parameters, and output deltas.
5. Interop schema starts with CSV-first Mental Modeler contract.
## Background Jobs and Index Lifecycle
Scheduler tasks to add:
1. `sync_graph_entity`
2. `sync_graph_project`
3. `reindex_graph_project`
Lifecycle rules:
1. Note writes, edits, moves, and deletes schedule graph-index sync tasks.
2. Scheduling pattern mirrors existing vector sync behavior.
3. On stale or missing graph index, request path serves via SQL fallback and schedules catch-up.
4. Reindex is idempotent and safe to rerun.
5. Index version metadata is tracked per project for staleness checks.
Operational behaviors:
1. Foreground requests never block on full reindex completion.
2. Background job failures surface in health endpoints with actionable status.
3. Reindex job can run incremental or full mode.
4. Phase 1 note: scheduler task names and reindex enqueue path are implemented; write/edit/move/delete sync hooks still need explicit wiring.
## MCP and CLI Surface
New MCP tools:
1. `graph_lineage`
2. `graph_impact`
3. `graph_health`
4. `fcm_simulate`
5. `fcm_rank_actions`
6. `fcm_import_model`
7. `fcm_export_model`
CLI passthrough additions:
1. `bm tool graph-lineage ...`
2. `bm tool graph-impact ...`
3. `bm tool graph-health ...`
4. `bm tool fcm-simulate ...`
5. `bm tool fcm-rank-actions ...`
6. `bm tool fcm-import-model ...`
7. `bm tool fcm-export-model ...`
Output conventions:
1. Default output is JSON for MCP and CLI.
2. MCP supports optional `output_format="text"` for human-readable summaries.
3. CLI remains JSON-first to keep agent integration deterministic.
## Test Strategy (fast loop + gates)
### Slice-by-slice loop
For each vertical slice, implement in this order:
1. API contract and schema.
2. Typed MCP client.
3. MCP tool passthrough.
4. CLI passthrough.
5. Focused tests for API/MCP/CLI.
Fast checks per slice:
1. Targeted `pytest` for changed API, MCP, and CLI modules.
2. `just fast-check`.
3. `just doctor`.
4. `just test-graph-intel` for graph/FCM-only iteration loop.
Milestone gates:
1. SQLite unit and integration pass first.
2. Selective Postgres parity tests for new graph and FCM contracts.
3. Full `just check` before merge.
### Required test cases and scenarios
1. Casual user impact preview before note edit.
2. Decision audit: lineage plus evidence references explain recommendation.
3. Graph health deterministic output for fixed snapshot.
4. Research flow: import model -> simulate -> rank -> export.
5. Sparse and contradictory graph input degrades gracefully.
6. Interop round-trip preserves node/edge counts and signed weights.
7. Local and cloud parity on contract semantics and error model.
8. Stale index fallback path returns valid response and schedules catch-up.
## Rollout and Feature Flagging
Rollout controls:
1. Gate graph and FCM endpoints behind config flags first.
2. Add entitlement enforcement after behavior and reliability stabilize.
3. Keep existing tools and endpoints fully backward compatible.
Suggested flags:
1. `feature_graph_intelligence_enabled`
2. `feature_fcm_enabled`
3. `feature_graph_oxigraph_provider_enabled`
4. `feature_graph_sql_fallback_enabled`
Rollout sequence:
1. Enable contract skeleton in dev.
2. Enable graph features for internal alpha users.
3. Enable Oxigraph provider with fallback-on by default.
4. Enable FCM import/simulate/rank/export for research alpha users.
5. Promote to Local+ beta when acceptance criteria are met.
## Risks and Mitigations
1. Risk: graph query complexity increases p95 latency.
Mitigation: strict query caps, fallback path, and performance budgets per endpoint.
2. Risk: stale index produces confusing outputs.
Mitigation: explicit staleness checks, SQL fallback, and background catch-up scheduling.
3. Risk: FCM recommendations appear opaque.
Mitigation: require evidence references, confidence fields, and deterministic simulation metadata.
4. Risk: local/cloud contract drift.
Mitigation: shared schemas, contract tests, and parity checks in CI gates.
5. Risk: integration surface grows faster than team can validate.
Mitigation: phase gates and vertical-slice completion before opening next phase.
## Improvement Backlog (Post-Phase 1)
1. Refactor `bm tool` graph/FCM commands into a dedicated CLI module to reduce `tool.py` size and improve maintainability.
2. Consolidate repeated MCP text-formatting helpers for graph/FCM outputs.
3. Replace deterministic placeholder graph behavior with SQL-backed lineage/impact/health implementations.
4. Add explicit config/entitlement enforcement for graph/FCM endpoints and tools.
5. Add performance telemetry and p95 reporting for graph and FCM routes.
6. Add parity and degradation tests for stale-index fallback and contradictory/sparse inputs.
## Acceptance Criteria
1. All required sections in this document are complete with no unresolved decisions.
2. API/interface contracts are implementation-ready with request, response, error, latency, and scale details.
3. Architecture alignment is explicit: FastAPI logic core, MCP/CLI facades, shared local/cloud contracts.
4. Delivery phases define concrete outputs and exit criteria.
5. Test strategy includes tight iteration loop and milestone gates.
6. Required scenario matrix is covered in test plan and mapped to implementation phases.
7. Rollout plan includes feature flags and backward compatibility guarantees.
8. An implementer can execute this plan without additional architecture clarification.
## Assumptions and Defaults
1. Config-flag gating first; entitlement wiring later.
2. Graph-first vertical slices before deep FCM expansion.
3. Atomic tools first; orchestration layer deferred.
4. JSON-first contracts for agent usability.
5. No breaking changes to existing `build_context` and `search_notes`.
## Out of Scope
1. Implementation details unrelated to graph/FCM delivery phases in this document.
2. Migration execution.
3. Pricing and positioning rewrites.
4. Cloud infrastructure changes in this phase.
@@ -0,0 +1,782 @@
# SPEC-LOCAL-GRAPH-INTELLIGENCE-MASTER: Local+ Graph Intelligence Blueprint
**Status:** Draft (Iteration 2, Decision-Complete)
**Date:** 2026-03-05
**Owner:** Basic Memory
**Primary Audience:** Internal build team (Product, Engineering, GTM)
**Current Phase (2026-03-05):** Implementation Plan Phase 1 is complete; Phase 2 (SQL-backed graph logic) is the active engineering phase.
**Related Specs:**
1. `/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE.md`
2. `/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE-TECHNICAL-ADDENDUM.md`
3. `/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE-IMPLEMENTATION-PLAN.md`
Reading guide:
1. Sections 1-5 define the business and product decisions.
2. Sections 6-10 define architecture and interface contracts.
3. Sections 11-14 define pricing, rollout, and decision gates for execution.
## 1) Executive Thesis
Basic Memory will ship **Local+ Graph Intelligence** as a premium local capability that upgrades the product from retrieval to decision support.
Positioning statement:
1. "Keep your local workflow. Add decision intelligence as complexity grows."
2. The product sells safer decisions and explainable recommendations, not graph database mechanics.
Locked thesis decisions:
1. SQLite will remain the operational core.
2. Markdown will remain source of truth.
3. Graph and FCM indexes will be derived and rebuildable.
4. Oxigraph/pyoxigraph will be the v1 graph index path.
5. FCM simulation will run in a Python service layer.
6. SurrealDB and FalkorDB will not be core dependencies in v1 due license-roadmap mismatch.
7. Product messaging will sell outcomes (safer decisions, explainable recommendations), not database internals.
## 2) Problem and Opportunity
Current state after v0.19:
1. Recursive SQL traversal can retrieve connected notes but becomes expensive and noisy after a few hops.
2. Users still do manual synthesis for impact analysis, decision lineage, and contradiction resolution.
3. Researchers need causal reasoning and scenario modeling, not only graph navigation.
Opportunity:
1. Deliver a premium local tier that materially improves decision quality while keeping data local.
2. Create a bridge from knowledge graph navigation to causal simulation (FCM).
3. Open a research-heavy market segment that values explainability and model interoperability.
Business opportunity:
1. Add a middle tier between free OSS and cloud subscription.
2. Preserve an upgrade path to hosted collaboration for research teams later.
3. Differentiate Basic Memory for research-grade workflows without forcing cloud adoption.
## 3) User Segments and Jobs-to-be-Done
| Segment | Primary Job-to-be-Done | Pain Today | Value Trigger |
|---|---|---|---|
| Casual local builder | Avoid breaking related notes when editing | Hidden dependencies and rework | Impact preview before edits |
| Solo technical founder | Keep architecture and decision context coherent | Context overload and drift | Decision lineage + impact radius |
| Research user | Model and test intervention strategies | No integrated causal simulation with notes | FCM simulation + action ranking |
| Product/research lead | Synthesize evidence quickly across many docs | Fragmented understanding | Path exploration + priority briefs |
## 4) Product Outcomes (not feature list)
Local+ Graph Intelligence will optimize for these outcomes:
1. **Change Safety:** users catch downstream impacts before they edit.
2. **Decision Clarity:** users can explain why an answer or recommendation was produced.
3. **Knowledge Health:** users keep larger graphs coherent with less manual audit work.
4. **Research Leverage:** users run scenario-level reasoning tied to explicit evidence.
Outcome metrics (for 30-day retained Local+ cohorts):
1. Median time-to-understanding for complex topics decreases by at least 35% for active Local+ users.
2. User-reported surprise side effects after note edits decrease by at least 30%.
3. At least 60% of active Local+ users invoke graph intelligence features weekly.
4. At least 40% of research-profile Local+ users invoke one FCM workflow weekly.
## 5) Feature Set v1/v1.5/v2
### v1 (post-v0.19 launch scope)
Included:
1. Decision Lineage
2. Impact Radius
3. Path Explorer (guided)
4. Graph Health (orphans, stale-central nodes, overloaded hubs)
5. CSV FCM import/export (nodes and edges)
6. FCM simulation for explicit action scenarios
7. FCM action ranking with evidence-linked rationale
Excluded:
1. Native Mental Modeler project format write support
2. Team governance and shared model policy controls
3. Cloud-only enhancements
### v1.5
Included:
1. Contradiction Watch with reconciliation queue
2. Priority Briefs (graph + FCM leverage summary)
3. Stronger uncertainty propagation in FCM scoring
4. Cloud execution optionality for heavy simulation jobs
### v2
Included:
1. Team-shared model governance
2. Hosted collaboration features for research teams
3. Optional native model translators beyond CSV baseline
Cut line policy:
1. If a capability cannot meet explainability requirements, it moves to v1.5+.
2. If a capability requires cloud to function, it cannot be marked v1.
3. If a capability cannot meet local performance envelopes, it cannot be promoted into default workflows.
## 6) Technical Architecture (Two-Graph Model)
### High-level architecture
```mermaid
flowchart LR
A[Markdown Files Source of Truth] --> B[Parser + Sync Pipeline]
B --> C[SQLite Operational Store]
B --> D[Derived Knowledge Graph Index Oxigraph]
C --> E[Graph Intelligence Service]
D --> E
E --> F[Lineage Impact Path Health APIs]
C --> G[FCM Service Python]
D --> G
G --> H[Simulation Ranking Interop APIs]
```
### Two-graph model
1. **Knowledge Graph (descriptive):** notes, decisions, concepts, and typed relations.
2. **FCM Graph (causal):** signed weighted influence links between goals, drivers, risks, and interventions.
### Core architectural decisions
1. SQLite is authoritative for entities, observations, relations, metadata, embeddings, and project state.
2. Oxigraph is a derived index for multi-hop graph traversal and graph-pattern retrieval.
3. FCM calculations run in Python using explicit model state and deterministic numerical steps.
4. Local mode runs fully offline.
5. Cloud mode can execute the same contracts via adjunct services while Neon remains system of record.
## 7) Backend Decision and Trade-Offs
### Final recommendation
Use this stack for v1:
1. SQLite (existing): primary operational store.
2. Oxigraph/pyoxigraph: derived knowledge graph index.
3. Python FCM service: causal simulation and ranking.
Decision rationale:
1. This preserves local-first UX while enabling deeper traversal and causal simulation.
2. This avoids restrictive licensing dependencies in the core product path.
3. This keeps a clean cloud portability path where Neon remains the hosted system of record.
### Trade-off matrix
| Option | Strengths | Risks | Decision |
|---|---|---|---|
| SQLite + Oxigraph + Python FCM | Local-first, permissive licensing, clear service boundaries, cloud-portable | Requires translation layer for query ergonomics | **Adopt v1** |
| Apache AGE on Postgres | SQL+graph in one engine, good cloud-side graph semantics | Neon extension support uncertainty, weaker local/cloud parity with SQLite local baseline | Defer |
| SurrealDB | Strong integrated multi-model experience | BSL posture conflicts with future hosted/open strategy timing | Reject for v1 core |
| FalkorDB | Graph performance and Redis ecosystem familiarity | SSPL posture conflicts with hosted/open strategy | Reject for v1 core |
## 8) Public APIs / Interfaces
All APIs are proposed MCP tool contracts for Local+ mode.
### Common conventions
1. `project` parameter is optional and follows existing Basic Memory project routing.
2. Deterministic fields are reproducible with identical inputs and index state.
3. Probabilistic fields are model-derived scores and include confidence metadata.
4. Error model uses structured codes and fail-fast behavior.
Shared error codes:
1. `INVALID_ARGUMENT`
2. `NOT_FOUND`
3. `MODEL_INVALID`
4. `INDEX_NOT_READY`
5. `RESOURCE_LIMIT_EXCEEDED`
6. `INTERNAL_ERROR`
---
### 8.1 `graph_lineage(start, goal?)`
**Input schema:**
```json
{
"start": "string (required, permalink or memory URL)",
"goal": "string (optional, concept or decision target)",
"max_hops": "integer (optional, default 4, range 1-6)",
"relation_filters": ["string"],
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"root": {"id": "string", "title": "string", "permalink": "string"},
"paths": [
{
"path_id": "string",
"nodes": [{"id": "string", "title": "string"}],
"edges": [{"relation": "string", "direction": "outgoing|incoming"}],
"deterministic_path_score": 0.0,
"confidence": 0.0,
"evidence_refs": ["memory://..."]
}
],
"generated_at": "RFC3339"
}
```
**Deterministic fields:** root, nodes, edges, deterministic path score, generated timestamp.
**Probabilistic fields:** confidence.
**Latency target:** p95 <= 450ms for `max_hops<=4`, graph envelope up to 50k nodes / 300k edges.
**Scale envelope:**
1. Tested local baseline: 50k nodes, 300k edges.
2. Expected degradation: path expansion can exceed latency target when candidate paths > 20k.
---
### 8.2 `graph_impact(target, horizon, relation_filters?)`
**Input schema:**
```json
{
"target": "string (required)",
"horizon": "integer (required, range 1-4)",
"relation_filters": ["string"],
"include_reasons": "boolean (default true)",
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"target": {"id": "string", "title": "string"},
"affected": [
{
"id": "string",
"title": "string",
"distance": 2,
"impact_score": 0.0,
"confidence": 0.0,
"reasons": ["string"]
}
],
"summary": {"total_considered": 0, "total_returned": 0}
}
```
**Deterministic fields:** membership, distance, summary counts.
**Probabilistic fields:** impact score, confidence.
**Latency target:** p95 <= 650ms for `horizon<=3` under baseline envelope.
**Scale envelope:**
1. `affected` default cap: 200 items.
2. Hard cap: 1000 items with pagination token.
---
### 8.3 `graph_health(scope?, timeframe?)`
**Input schema:**
```json
{
"scope": "string (optional, directory prefix or project-wide)",
"timeframe": "string (optional, e.g. 30d, 90d)",
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"metrics": {
"orphan_rate": 0.0,
"stale_central_nodes": 0,
"overloaded_hubs": 0,
"contradiction_candidates": 0
},
"issues": [
{
"issue_type": "orphan|stale_central|overloaded_hub|contradiction_candidate",
"entity_id": "string",
"severity": "low|medium|high",
"reason": "string",
"suggested_action": "string"
}
],
"computed_at": "RFC3339"
}
```
**Deterministic fields:** metrics and issue list membership for a fixed graph snapshot.
**Probabilistic fields:** contradiction candidate confidence when present.
**Latency target:** p95 <= 1500ms project-wide; <= 700ms for scoped directory mode.
**Scale envelope:** project-wide scans tested to 50k nodes.
---
### 8.4 `fcm_simulate(actions, scenario?, clamp_rules?)`
**Input schema:**
```json
{
"actions": [
{"node_id": "string", "delta": 0.2}
],
"scenario": {
"steps": 12,
"activation": "tanh|sigmoid|bounded_linear",
"decay": 0.05
},
"clamp_rules": [
{"node_id": "string", "min": -1.0, "max": 1.0}
],
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"baseline": [{"node_id": "string", "state": 0.12}],
"projected": [{"node_id": "string", "state": 0.43}],
"deltas": [{"node_id": "string", "delta": 0.31}],
"stability": {
"converged": true,
"iterations_used": 9,
"residual": 0.002
},
"confidence": 0.0,
"explanations": [
{"node_id": "string", "top_influencers": [{"source": "string", "weight": 0.7}]}
]
}
```
**Deterministic fields:** baseline, projected, deltas, convergence metadata for fixed model and parameters.
**Probabilistic fields:** confidence (derived from edge confidence and evidence coverage).
**Latency target:** p95 <= 1000ms for up to 500 nodes / 5000 edges and <=12 steps.
**Scale envelope:**
1. Soft limit: 2000 nodes / 20000 edges.
2. Over soft limit: return `RESOURCE_LIMIT_EXCEEDED` with remediation guidance.
---
### 8.5 `fcm_rank_actions(goal, constraints?, top_k?)`
**Input schema:**
```json
{
"goal": "string (required node_id)",
"constraints": {
"max_negative_impact": 0.25,
"required_tags": ["string"],
"disallowed_nodes": ["string"]
},
"top_k": "integer (default 10, range 1-25)",
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"goal": {"node_id": "string", "label": "string"},
"recommendations": [
{
"action_node_id": "string",
"expected_goal_delta": 0.0,
"risk_penalty": 0.0,
"net_score": 0.0,
"confidence": 0.0,
"rationale": ["string"],
"evidence_refs": ["memory://..."]
}
]
}
```
**Deterministic fields:** candidate action set, constraints compliance.
**Probabilistic fields:** expected goal delta, risk penalty, net score, confidence.
**Latency target:** p95 <= 1500ms for top 10 from up to 100 candidate actions.
**Scale envelope:**
1. Candidate actions hard cap: 1000.
2. For larger sets, require pre-filtering via tags/scope.
---
### 8.6 `fcm_import_model(source, format)`
**Input schema:**
```json
{
"source": "string (required path or URI)",
"format": "csv_bundle_v1 (required)",
"merge_mode": "replace|upsert (default upsert)",
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"import_id": "string",
"nodes_loaded": 0,
"edges_loaded": 0,
"warnings": ["string"],
"errors": ["string"]
}
```
**Deterministic fields:** load counts and validation results.
**Probabilistic fields:** none.
**Latency target:** p95 <= 2500ms for 10k edges CSV bundle.
**Scale envelope:**
1. Maximum CSV rows per import: 250k.
2. Above limit returns `RESOURCE_LIMIT_EXCEEDED`.
---
### 8.7 `fcm_export_model(format, selection?)`
**Input schema:**
```json
{
"format": "csv_bundle_v1 (required)",
"selection": {
"scope": "all|tag|subgraph",
"tag": "string (optional)",
"seed_nodes": ["string"]
},
"project": "string (optional)"
}
```
**Output schema:**
```json
{
"export_id": "string",
"format": "csv_bundle_v1",
"files": [
{"name": "nodes.csv", "path": "string"},
{"name": "edges.csv", "path": "string"}
],
"node_count": 0,
"edge_count": 0
}
```
**Deterministic fields:** file set and row counts for fixed selection.
**Probabilistic fields:** none.
**Latency target:** p95 <= 1800ms for 50k edges export.
**Scale envelope:**
1. Max export rows: 500k total.
2. Pagination or scoped export required above cap.
## 9) Data Model and Storage Boundaries
### 9.1 Knowledge graph schema (descriptive)
`KnowledgeNode`:
1. `id: str`
2. `kind: note|decision|spec|concept|person|project`
3. `title: str`
4. `permalink: str`
5. `tags: list[str]`
6. `updated_at: datetime`
`KnowledgeEdge`:
1. `id: str`
2. `src_id: str`
3. `dst_id: str`
4. `relation: str`
5. `directionality: directed|bidirectional`
6. `evidence_refs: list[str]`
7. `confidence: float [0,1]`
8. `updated_at: datetime`
### 9.2 FCM schema (causal signed weighted)
`FCMNode`:
1. `id: str`
2. `label: str`
3. `node_type: goal|driver|risk|intervention|context`
4. `state: float [-1,1]`
5. `clamp_min: float`
6. `clamp_max: float`
7. `metadata: map`
`FCMEdge`:
1. `id: str`
2. `source_id: str`
3. `target_id: str`
4. `weight: float [-1,1]`
5. `confidence: float [0,1]`
6. `time_decay: float [0,1]`
7. `evidence_refs: list[str]`
8. `updated_at: datetime`
### 9.3 Provenance model
`ProvenanceRecord`:
1. `entity_id: str`
2. `evidence_refs: list[str]`
3. `confidence: float [0,1]`
4. `updated_at: datetime`
5. `source_type: extracted|user_authored|imported`
### 9.4 Scenario model
`Scenario`:
1. `id: str`
2. `name: str`
3. `interventions: list[{node_id, delta}]`
4. `constraints: list[{node_id, min, max}]`
5. `steps: int`
6. `activation: tanh|sigmoid|bounded_linear`
7. `created_at: datetime`
8. `created_by: str`
`ScenarioResult`:
1. `scenario_id: str`
2. `converged: bool`
3. `iterations_used: int`
4. `residual: float`
5. `goal_deltas: list[{node_id, delta}]`
6. `confidence: float [0,1]`
### 9.5 Storage boundaries
| Layer | System of Record | Purpose | Rebuildable |
|---|---|---|---|
| Markdown files | File system | Canonical knowledge content | No |
| SQLite entities/relations/embeddings | SQLite | Operational queries and project state | Yes (from markdown + embedding pipeline) |
| Knowledge graph triples | Oxigraph | Fast graph traversal and pattern queries | Yes |
| FCM model and snapshots | SQLite + optional artifacts | Causal model state and scenario history | Yes (from imports and authored model definitions) |
## 10) Mental Modeler Interoperability
### v1 interoperability contract
Format: `csv_bundle_v1`
1. `nodes.csv`
2. `edges.csv`
3. Optional `scenarios.csv`
`nodes.csv` required columns:
1. `node_id`
2. `label`
3. `node_type`
4. `state`
5. `clamp_min`
6. `clamp_max`
`edges.csv` required columns:
1. `edge_id`
2. `source_id`
3. `target_id`
4. `weight`
5. `confidence`
6. `evidence_refs` (semicolon-delimited)
### Import rules
1. Missing required columns fail with `MODEL_INVALID`.
2. Unknown node types fail fast.
3. Weight and confidence ranges are strictly validated.
4. Import returns warnings for dangling evidence references.
### Export rules
1. Preserve stable IDs for round-trip compatibility.
2. Preserve signed weights exactly.
3. Preserve confidence values exactly.
4. Non-portable metadata is emitted to `metadata.json` sidecar when present.
### Native file translators
1. Native project-format translation is deferred to v2.
2. CSV remains the guaranteed compatibility baseline in v1 and v1.5.
## 11) Pricing and Packaging
### Tier structure
| Tier | Price Monthly | Price Annual | Beta Price (25% off) | Target Persona | Core Value |
|---|---:|---:|---:|---|---|
| OSS Local | $0 | $0 | $0 | Casual local users | Retrieval and memory basics |
| Local+ Graph Intelligence | $9 | $90 | $6.75 monthly / $67.50 annual | Founders, consultants, researchers | Safer changes + explainable graph + FCM simulation |
| Cloud Pro (current anchor) | $19 | $190 | $14.25 monthly / $142.50 annual | Users who need hosted sync and cloud workflows | Managed cloud + sync + collaboration path |
Pricing principles:
1. Local+ is intentionally priced between free OSS and cloud to capture users who need deeper intelligence but not hosted sync.
2. Cloud Pro remains the hosted convenience anchor and future collaboration path.
3. Local+ must stand on standalone local value and cannot depend on cloud features.
### Feature gate mapping
| Capability | OSS Local | Local+ | Cloud Pro |
|---|---|---|---|
| Search and basic context tools | Yes | Yes | Yes |
| Decision Lineage | No | Yes | Yes |
| Impact Radius | No | Yes | Yes |
| Graph Health | No | Yes | Yes |
| FCM simulate + rank | No | Yes | Yes |
| CSV model import/export | No | Yes | Yes |
| Hosted collaboration controls | No | No | Future add-on |
### Packaging decisions
1. Local+ remains fully local-capable and does not require cloud auth to run.
2. Cloud Pro remains the hosted convenience and collaboration anchor.
3. Future hosted research add-on will layer on Cloud Pro after v2 readiness.
## 12) Rollout Strategy
### 12.1 Document production iterations (locked)
Iteration 1 (draft complete):
1. Complete all 15 sections in one pass.
2. Include v1/v1.5/v2 cut lines.
3. Include pricing and scenario definitions.
4. Ensure no placeholders.
Iteration 2 (hardening and decision lock):
1. Resolve cross-section contradictions.
2. Convert uncertain language to locked decisions.
3. Add measurable acceptance criteria and risk owners.
4. Finalize execution-ready API contracts.
### 12.2 Product rollout phases
Phase A: Foundation release (v1)
1. Graph lineage, impact, health.
2. CSV model import/export.
3. FCM simulation and ranking.
4. Advanced mode UX gating for research-grade controls.
Phase B: Quality and confidence (v1.5)
1. Contradiction Watch.
2. Priority Briefs.
3. Improved uncertainty propagation.
4. Confidence calibration pass using real-world model feedback.
Phase C: Team expansion (v2)
1. Hosted team governance.
2. Shared model controls.
3. Extended translator support.
### 12.3 Go/No-Go release gates
Gate to ship v1 default workflows:
1. p95 latency targets are met within the declared scale envelopes.
2. Scenario round-trip fidelity tests pass for CSV import/export.
3. Every recommendation and simulation path exposes evidence references and confidence.
Gate to ship v1.5:
1. Contradiction Watch precision is acceptable for default-on use.
2. Confidence calibration reduces false-confidence reports in user testing.
Gate to ship v2 team features:
1. Clear willingness-to-pay signal from team and research buyers.
2. Cloud execution path preserves local-cloud semantic parity for core contracts.
## 13) Risks, Counterarguments, and Mitigations
| Risk | Counterargument | Severity | Likelihood | Mitigation | Owner |
|---|---|---|---|---|---|
| "This is just better search" | Positioning can collapse into technical jargon | High | Medium | Lead with decision safety and explainability outcomes in product copy and onboarding | Product Lead |
| FCM feels opaque or invented | Users distrust black-box scoring | High | Medium | Require evidence refs and confidence disclosure on every recommendation | Applied AI Lead |
| Local performance regressions | Multi-hop and simulation can feel slow on laptops | Medium | Medium | Enforce envelopes, caps, and fail-fast limit errors with guidance | Engineering Lead |
| Research features overwhelm casual users | UX complexity can reduce adoption | Medium | High | Default to guided flows and hide advanced controls behind explicit advanced mode | Design Lead |
| License/roadmap conflict if backend changes | Later swap to restrictive engines creates GTM risk | High | Low | Lock permissive v1 stack and require leadership sign-off for any license-restricted dependency | Product + Legal |
| Interop mismatch with external tools | Round-trip drift harms trust with researchers | Medium | Medium | Validate node and edge parity in import/export tests and version interop schema | Integrations Lead |
| Pricing confusion between Local+ and Cloud Pro | Buyers may not understand which tier fits | Medium | Medium | Publish explicit tier comparison focused on local intelligence vs hosted collaboration | GTM Lead |
## 14) Acceptance Criteria
### 14.1 Document acceptance criteria
1. Product, technical, and pricing decisions are explicit and unambiguous.
2. API contracts include input/output schemas, error models, deterministic versus probabilistic fields, and performance envelopes.
3. v1/v1.5/v2 cut lines are explicit and consistent.
4. Risk register includes severity, likelihood, owner, and mitigation.
5. Document can be handed to implementation without additional architecture decisions.
6. Leadership can use this document directly for pricing and positioning decisions.
### 14.2 Product acceptance criteria for v1 delivery
1. `graph_lineage`, `graph_impact`, `graph_health`, `fcm_simulate`, `fcm_rank_actions`, `fcm_import_model`, and `fcm_export_model` are available as Local+ contracts.
2. Local mode executes all v1 contracts without cloud dependency.
3. API p95 latency targets are met within defined scale envelopes.
4. Every ranked or simulated output includes evidence-linked rationale and confidence.
5. CSV round-trip preserves node count, edge count, and signed weights exactly.
### 14.3 Scenario test matrix (required)
1. **Casual local user impact check**
Expected pass:
`graph_impact` returns ranked affected notes with reasons before a note edit.
2. **Research workflow simulation**
Expected pass:
User imports a model bundle, runs `fcm_simulate`, and receives converged deltas and rationale.
3. **Decision audit traceability**
Expected pass:
`graph_lineage` returns path and evidence references that explain recommendation origin.
4. **Cloud and local parity**
Expected pass:
Identical query inputs return semantically equivalent outputs in local and cloud modes with local fallback behavior when cloud is unavailable.
5. **Sparse or contradictory graph behavior**
Expected pass:
System degrades gracefully with explicit uncertainty and does not fabricate high-confidence recommendations.
6. **Interop round-trip fidelity**
Expected pass:
`fcm_export_model` then `fcm_import_model` preserves node and edge counts and signed weights without mutation.
## 15) Appendix (license notes, terminology, examples)
### 15.1 License notes (verified 2026-03-05)
1. SurrealDB core licensing is published under BSL 1.1 with DBaaS-related restrictions in its conversion window.
2. FalkorDB is published under SSPLv1.
3. pyoxigraph is dual-licensed Apache-2.0 or MIT.
4. Neon extension catalog currently does not list Apache AGE as a supported extension.
These notes support the v1 dependency decisions in this document.
### 15.2 Terminology
1. **Knowledge graph:** descriptive relation graph derived from markdown knowledge.
2. **FCM:** fuzzy cognitive model with signed weighted causal edges.
3. **Deterministic field:** reproducible output field from fixed input and fixed model/index snapshot.
4. **Probabilistic field:** score influenced by confidence weights and model uncertainty.
### 15.3 Assumptions and defaults
1. Markdown remains source of truth.
2. SQLite remains mandatory baseline.
3. Graph and FCM capabilities are premium Local+ features, not OSS defaults.
4. Research-heavy features are advanced mode, while mainstream UX stays guided.
5. Mental Modeler interoperability starts with CSV contract first; native translator support is deferred.
### 15.4 Out of scope for this document
1. Implementation code changes.
2. Database migrations.
3. Cloud infrastructure edits.
4. Full instrumentation pilot plan as the primary artifact.
### 15.5 External references
1. [SurrealDB licensing](https://surrealdb.com/license)
2. [FalkorDB licensing](https://docs.falkordb.com/References/license.html)
3. [pyoxigraph package and license](https://pypi.org/project/pyoxigraph/)
4. [Neon Postgres extension catalog](https://neon.com/docs/extensions/pg-extensions)
@@ -0,0 +1,299 @@
# SPEC-LOCAL-GRAPH-INTELLIGENCE: Technical Addendum (Graph + FCM)
**Status:** Draft
**Date:** 2026-03-05
**Owner:** Basic Memory
**Current Phase (2026-03-05):** Contract skeleton implementation is complete; next active phase is SQL-backed graph capabilities.
Related product spec:
`/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE.md`
Related execution spec:
`/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE-IMPLEMENTATION-PLAN.md`
## Why This Addendum Exists
The product spec defines user value. This addendum defines the technical shape that can deliver that value without
breaking local-first principles.
This addendum also introduces a second graph layer:
1. Knowledge graph for relationships between notes, entities, and decisions.
2. Fuzzy Cognitive Model (FCM) graph for weighted causal reasoning over actions and outcomes.
Both are derived from markdown and optional user-provided models.
## Strategic Reality Check
This is a strong idea if we stage it correctly.
It is not a pipe dream if we avoid one trap: building a big "graph platform" before proving users repeatedly use
decision simulation workflows.
The correct strategy is:
1. Launch high-precision graph insights first.
2. Add FCM scoring where it changes user behavior (not as a novelty dashboard).
3. Expand to hosted/team workflows only after local usage proves repeat value.
## Constraints and Design Principles
1. SQLite remains the operational source for entities, observations, relations, and embeddings.
2. Markdown remains source of truth.
3. Graph indexes are derived, rebuildable, and disposable.
4. Premium local mode must run fully offline.
5. Cloud deployment should support both single-tenant and SaaS later.
6. Avoid licenses that constrain hosted/open-source strategy.
## Backend Recommendation
### Primary Recommendation
Use a dual-store architecture:
1. SQLite (existing): operational data, metadata filters, embeddings, and most retrieval.
2. Oxigraph/pyoxigraph (new): derived graph index for graph traversal and graph-pattern queries.
3. Python simulation layer (new): FCM state propagation, scenario runs, and decision scoring.
Why this is the best fit:
1. Permissive licensing profile.
2. Works locally with low footprint.
3. Cloud-compatible as sidecar service while keeping Neon Postgres as core cloud store.
4. Clear boundary between graph query and numeric simulation concerns.
### Candidate Trade-Offs
#### Oxigraph/pyoxigraph
Pros:
1. Lightweight local embedding.
2. Good fit for derived-index strategy.
3. Strong path for standards-based graph representation.
Cons:
1. SPARQL fluency is less common than SQL/Cypher.
2. Requires a translation layer so product features are not query-language-coupled.
#### Apache AGE (Postgres extension)
Pros:
1. SQL + graph in one engine.
2. Attractive for cloud-side graph operations.
Cons:
1. Neon support is uncertain for this extension.
2. Local/cloud parity is harder if local uses SQLite.
#### SurrealDB / FalkorDB
Pros:
1. Strong graph-oriented developer experience.
Cons:
1. License posture is misaligned with a future hosted/open-source roadmap unless commercial terms are accepted.
Decision:
Do not make these core dependencies for v1 of Local+ Graph Intelligence.
## Two-Graph Model
### A) Knowledge Graph (Descriptive)
Node examples:
1. Note
2. Decision
3. Spec
4. Person
5. Project
6. Concept
Edge examples:
1. `depends_on`
2. `informed_by`
3. `contradicts`
4. `supports`
5. `implements`
6. `derived_from`
Purpose:
Power navigation, lineage, path explanation, impact radius, and health checks.
### B) FCM Graph (Causal, Signed, Weighted)
Node examples:
1. Goal: "Reduce regressions"
2. Driver: "Test coverage"
3. Risk: "Scope creep"
4. Intervention: "Add review gate"
5. Context variable: "Team bandwidth"
Edge attributes:
1. `weight` in [-1.0, 1.0]
2. `confidence` in [0.0, 1.0]
3. `evidence_refs` (links to notes/specs)
4. `time_decay` (optional)
Purpose:
Power scenario simulation and action ranking, not generic retrieval.
## Premium Feature Mapping to Architecture
### Decision Lineage
Backed by:
1. Knowledge graph path queries.
2. Evidence references stored on edges.
### Impact Radius
Backed by:
1. Multi-hop neighborhood expansion with relation-type weights.
2. Risk ranking using centrality + recency + confidence.
### Contradiction Watch
Backed by:
1. Candidate contradiction edges.
2. Confidence-scored reconciliation queue.
### Priority Briefs
Backed by:
1. Health metrics (orphan rate, stale-central nodes, unresolved contradictions).
2. Optional FCM "top leverage actions" summary.
### New Premium Feature: Action Simulator
Backed by:
1. FCM scenario runs over selected action nodes.
2. Ranked interventions with expected positive/negative downstream effects.
3. Explicit rationale graph for every recommendation.
## Mental Modeler Interop Plan
Goal:
Make Basic Memory the AI-enabled operating layer around existing researcher workflows, not a replacement for their tools.
Interoperability phases:
1. Import/export edge lists and node tables via CSV as the baseline interchange.
2. Preserve concept IDs and metadata so round-trips remain stable.
3. Add translator support for native model files if/when schema contracts are validated with partner data.
Validation requirement:
1. Round-trip tests must preserve node count, edge count, and signed weights.
2. Confidence/evidence metadata may be Basic Memory extensions and should degrade gracefully when exported.
## Suggested Tool/API Surface (Product-Facing)
1. `graph_lineage(start, goal?)`
Returns explainable evidence paths.
2. `graph_impact(target, horizon=2..4)`
Returns ranked affected nodes with reasons.
3. `graph_health()`
Returns actionable graph quality issues.
4. `fcm_simulate(actions, scenario?)`
Returns projected effects and uncertainty.
5. `fcm_rank_actions(goal, constraints?)`
Returns top candidate actions with trade-offs.
6. `fcm_import_model(source)` / `fcm_export_model(format)`
Handles interop with external cognitive mapping workflows.
## Local and Cloud Deployment Shape
### Local (Primary)
1. SQLite + local embeddings.
2. Oxigraph as local sidecar/index library.
3. FCM simulation in process.
### Cloud (Future-Compatible)
1. Neon Postgres remains system of record in hosted mode.
2. Graph index service runs per tenant or shared multi-tenant with strict tenancy boundaries.
3. FCM simulation service can run stateless workers reading graph snapshots.
Principle:
Do not require cloud to run premium local features.
## Rollout Plan With Go/No-Go Gates
### Phase 0: Proof of Utility (4-6 weeks)
Deliver:
1. Decision Lineage
2. Impact Radius
3. CSV FCM import + `fcm_simulate` prototype
Gate to continue:
1. Repeated weekly usage by pilot users.
2. Users report changed decisions, not just curiosity clicks.
### Phase 1: Productized Local+ Beta
Deliver:
1. Graph health workflow
2. Contradiction Watch
3. Action ranking with explicit rationale
Gate to continue:
1. Retention of graph features after first month.
2. Measured reduction in "surprise side effects" after edits.
### Phase 2: Hosted Expansion
Deliver:
1. Optional cloud execution for heavy simulations.
2. Team-shared model governance.
Gate to continue:
1. Clear willingness to pay for hosted collaboration.
## Risks and Mitigations
Risk: FCM outputs feel "made up."
Mitigation: Require evidence links and confidence scoring in every recommendation.
Risk: Research-heavy feature alienates casual users.
Mitigation: Keep FCM features in an advanced mode; default to concise guidance workflows.
Risk: Overengineering early graph stack.
Mitigation: Keep derived-index architecture and strict phase gates tied to behavior change.
Risk: Interop friction with external tooling.
Mitigation: Start with transparent CSV contract and strict round-trip validation.
## Candid Recommendation
Pursue this. It is a high-upside differentiation path for Local+ if executed with staged validation.
The key is to sell outcomes:
1. "Safer decisions"
2. "Explainable recommendations"
3. "Faster synthesis for complex research"
Avoid selling "graph DB" as the product. That is implementation detail.
+262
View File
@@ -0,0 +1,262 @@
# SPEC-LOCAL-GRAPH-INTELLIGENCE: Premium Local Graph Intelligence
**Status:** Draft
**Date:** 2026-03-05
**Owner:** Basic Memory
**Current Phase (2026-03-05):** Phase 1 contract foundation shipped; engineering is now executing SQL-backed Phase 2 graph logic.
Companion technical addendum:
`/docs/specs/SPEC-LOCAL-GRAPH-INTELLIGENCE-TECHNICAL-ADDENDUM.md`
## Summary
Add a premium local feature that turns Basic Memory from "search and recall" into "explain and guide."
The value is not a new database. The value is better decisions for local users:
1. Understand why something matters.
2. See what will be affected before making a change.
3. Detect weak spots in the knowledge base early.
4. Navigate complex knowledge intentionally instead of loading everything.
This feature is additive. Existing local workflows remain intact.
## Positioning
Core message:
"Your notes do more than store knowledge. They reveal consequences, lineage, and blind spots."
Local user promise:
1. Keep files local.
2. Keep markdown as source of truth.
3. Get advanced graph intelligence as an opt-in premium capability.
## Problem
Today, deep graph navigation is possible but often expensive in context size and hard to steer for complex questions.
Users can find information, but they still do manual synthesis to answer:
1. What changed because of this note?
2. Why did we decide this?
3. What might break if I update this?
4. Which parts of the graph are stale, isolated, or contradictory?
The cost is time, cognitive load, and missed risk.
## Goals
1. Provide clear, explainable graph insights that users can act on.
2. Make deep navigation feel guided, not overwhelming.
3. Help users prevent mistakes before they happen.
4. Create premium local value that is easy to understand and justify.
5. Keep feature behavior transparent and trustworthy.
## Non-Goals
1. Replacing SQLite as the primary operational store.
2. Changing markdown as source of truth.
3. Forcing users to learn graph query languages.
4. Building a cloud-only feature set.
5. Turning Basic Memory into an enterprise BI product.
## Product Frame: From Retrieval to Reasoning
The feature should be framed as a shift in user outcome:
1. Retrieval: "Find me the note."
2. Reasoning: "Show me the path, impact, and confidence around this note."
This is the main narrative upgrade for premium local users.
## Premium Value Pillars
### 1) Decision Confidence
Users can see decision lineage:
1. What evidence supported a decision.
2. Which notes/specs informed it.
3. How that decision evolved over time.
### 2) Change Safety
Users can run impact-aware workflows:
1. Estimate blast radius before editing.
2. Surface downstream dependencies.
3. Prioritize what to review first.
### 3) Knowledge Quality
Users can maintain graph health:
1. Detect orphaned notes.
2. Detect overloaded hub notes.
3. Detect stale but high-centrality notes.
4. Detect likely contradictions.
### 4) Guided Navigation
Users can explore deeper relationships without context explosion:
1. Follow promising branches.
2. Stop when confidence is sufficient.
3. Avoid "load everything and hope."
## Feature Catalog (Value-First)
### A. Decision Lineage
What users get:
1. A clear "why chain" for important conclusions.
2. Traceable connections to supporting notes.
3. Better handoffs and historical understanding.
### B. Impact Radius
What users get:
1. A ranked list of likely affected notes before edits.
2. Safer refactors for docs, plans, and architecture.
3. Reduced accidental drift and inconsistency.
### C. Knowledge Health Dashboard
What users get:
1. Weekly health signals for the graph.
2. Actionable cleanup targets.
3. Better long-term memory quality with less manual auditing.
### D. Path Explorer
What users get:
1. "Show me how A connects to B" style explanations.
2. Multiple candidate paths with confidence cues.
3. Better discovery across large note collections.
### E. Contradiction Watch
What users get:
1. Early warnings for conflicting statements.
2. Suggested reconciliation workflow.
3. Higher trust in the knowledge base.
### F. Priority Briefs
What users get:
1. Periodic "what matters now" graph summaries.
2. Focused recommendations, not noisy activity dumps.
3. Better focus for solo builders and small teams.
## User Personas and Why They Pay
### Solo Technical Founder
Pain:
Cannot hold full architecture and decision history in working memory.
Premium value:
Impact Radius + Decision Lineage prevent rework and regressions.
### Product/Research Lead
Pain:
Knowledge is fragmented across specs, notes, and decisions.
Premium value:
Path Explorer + Priority Briefs compress synthesis time.
### Consultant/Fractional Operator
Pain:
Frequent context switching across domains and clients.
Premium value:
Knowledge Health + Decision Lineage speed onboarding and reporting.
## Packaging Direction
Suggested packaging:
1. OSS Local: existing search + context tools.
2. Local+ Graph Intelligence: advanced graph insight features listed above.
3. Future Team Add-On: shared policies, shared graph health views, shared lineage views.
Core upsell line:
"Keep your local workflow. Add graph intelligence when complexity grows."
## Experience Principles
1. Explainability first.
Every advanced result should show "why this was suggested."
2. Actionability over novelty.
Insights should lead to concrete next steps, not abstract charts.
3. Progressive disclosure.
Start with concise summaries, expand on demand.
4. Deterministic where possible.
Users should trust repeated runs of the same workflow.
5. Respect local-first expectations.
No surprise cloud dependency in premium local mode.
## Success Criteria (Product)
1. Users can describe the benefit in one sentence:
"It shows me what matters and what breaks before I change things."
2. Premium users report lower time-to-understanding for complex topics.
3. Premium users report fewer "surprise side effects" after edits.
4. Premium users keep larger knowledge graphs healthy with less manual effort.
5. Feature adoption is driven by outcomes, not by curiosity-only usage.
## Risks and Mitigations
Risk: Feature sounds like "just better search."
Mitigation: Lead messaging with decision confidence and change safety, not traversal depth.
Risk: Feature feels too advanced for normal users.
Mitigation: Package as guided insights and reports, not as a query language.
Risk: Insight quality feels noisy.
Mitigation: Focus launch scope on high-precision insight types and transparent rationale.
Risk: Value is hard to prove.
Mitigation: Track user-facing outcomes (time saved, risk avoided, cleanup completed).
## Rollout Narrative
Phase 1: "Safer Changes"
1. Impact Radius
2. Decision Lineage
Phase 2: "Health and Clarity"
1. Knowledge Health Dashboard
2. Contradiction Watch
Phase 3: "Strategic Navigation"
1. Path Explorer
2. Priority Briefs
## One-Line Positioning Options
1. "Local notes, strategic intelligence."
2. "Know what changed, why it matters, and what it affects."
3. "From note-taking to decision support."
## Open Questions
1. Which two features best define the paid tier at launch?
2. Which insight types should be guaranteed deterministic in v1?
3. Should Priority Briefs be bundled or separate as an add-on?
4. What is the simplest in-product education flow for first-time premium users?
-18
View File
@@ -69,24 +69,6 @@ testmon *args:
test-smoke:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m smoke test-int/mcp/test_smoke_integration.py
# Run graph intelligence API contract tests only
test-graph-intel-api:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests/api/v2/test_graph_intelligence_router.py
# Run graph intelligence MCP tests only
test-graph-intel-mcp:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests/mcp/clients/test_graph_clients.py tests/mcp/test_tool_graph_intelligence.py tests/mcp/test_tool_contracts.py
# Run graph intelligence CLI passthrough tests only
test-graph-intel-cli:
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov tests/cli/test_cli_tool_graph_intelligence_json_output.py
# Run the full graph intelligence fast iteration slice
test-graph-intel:
just test-graph-intel-api
just test-graph-intel-mcp
just test-graph-intel-cli
# Fast local loop: lint, format, typecheck, impacted tests
fast-check:
just fix
-4
View File
@@ -19,8 +19,6 @@ from basic_memory.api.v2.routers import (
prompt_router as v2_prompt,
importer_router as v2_importer,
schema_router as v2_schema,
graph_router as v2_graph,
fcm_router as v2_fcm,
)
from basic_memory.api.v2.routers.project_router import (
add_project,
@@ -88,8 +86,6 @@ app.include_router(v2_directory, prefix="/v2/projects/{project_id}")
app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
app.include_router(v2_schema, prefix="/v2/projects/{project_id}")
app.include_router(v2_graph, prefix="/v2/projects/{project_id}")
app.include_router(v2_fcm, prefix="/v2/projects/{project_id}")
app.include_router(v2_project, prefix="/v2")
# Legacy web app proxy paths (compat with /proxy/projects/projects)
-4
View File
@@ -21,8 +21,6 @@ from basic_memory.api.v2.routers import (
directory_router,
prompt_router,
importer_router,
graph_router,
fcm_router,
)
__all__ = [
@@ -34,6 +32,4 @@ __all__ = [
"directory_router",
"prompt_router",
"importer_router",
"graph_router",
"fcm_router",
]
@@ -9,8 +9,6 @@ from basic_memory.api.v2.routers.directory_router import router as directory_rou
from basic_memory.api.v2.routers.prompt_router import router as prompt_router
from basic_memory.api.v2.routers.importer_router import router as importer_router
from basic_memory.api.v2.routers.schema_router import router as schema_router
from basic_memory.api.v2.routers.graph_router import router as graph_router
from basic_memory.api.v2.routers.fcm_router import router as fcm_router
__all__ = [
"knowledge_router",
@@ -22,6 +20,4 @@ __all__ = [
"prompt_router",
"importer_router",
"schema_router",
"graph_router",
"fcm_router",
]
@@ -1,61 +0,0 @@
"""V2 router for FCM simulation and interop endpoints."""
from fastapi import APIRouter
from basic_memory.deps import FCMServiceV2ExternalDep, ProjectExternalIdPathDep
from basic_memory.schemas.graph_intelligence import (
FCMExportRequest,
FCMExportResponse,
FCMImportRequest,
FCMImportResponse,
FCMRankActionsRequest,
FCMRankActionsResponse,
FCMSimulateRequest,
FCMSimulateResponse,
)
router = APIRouter(prefix="/fcm", tags=["fcm-v2"])
@router.post("/simulate", response_model=FCMSimulateResponse)
async def fcm_simulate(
request: FCMSimulateRequest,
fcm_service: FCMServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
) -> FCMSimulateResponse:
"""Run an FCM scenario simulation."""
_ = project_id
return await fcm_service.simulate(request)
@router.post("/rank-actions", response_model=FCMRankActionsResponse)
async def fcm_rank_actions(
request: FCMRankActionsRequest,
fcm_service: FCMServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
) -> FCMRankActionsResponse:
"""Rank action candidates toward a goal."""
_ = project_id
return await fcm_service.rank_actions(request)
@router.post("/import", response_model=FCMImportResponse)
async def fcm_import(
request: FCMImportRequest,
fcm_service: FCMServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
) -> FCMImportResponse:
"""Import an FCM model using a supported interchange format."""
_ = project_id
return await fcm_service.import_model(request)
@router.post("/export", response_model=FCMExportResponse)
async def fcm_export(
request: FCMExportRequest,
fcm_service: FCMServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
) -> FCMExportResponse:
"""Export an FCM model using a supported interchange format."""
_ = project_id
return await fcm_service.export_model(request)
@@ -1,71 +0,0 @@
"""V2 router for graph intelligence endpoints."""
from fastapi import APIRouter, Query
from basic_memory.deps import (
GraphIntelligenceServiceV2ExternalDep,
ProjectExternalIdPathDep,
TaskSchedulerDep,
)
from basic_memory.schemas.graph_intelligence import (
GraphHealthResponse,
GraphImpactRequest,
GraphImpactResponse,
GraphLineageRequest,
GraphLineageResponse,
GraphReindexRequest,
GraphReindexResponse,
)
router = APIRouter(prefix="/graph", tags=["graph-v2"])
@router.post("/lineage", response_model=GraphLineageResponse)
async def graph_lineage(
request: GraphLineageRequest,
graph_service: GraphIntelligenceServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
) -> GraphLineageResponse:
"""Build lineage paths from a start node toward an optional goal."""
_ = project_id
return await graph_service.lineage(request)
@router.post("/impact", response_model=GraphImpactResponse)
async def graph_impact(
request: GraphImpactRequest,
graph_service: GraphIntelligenceServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
) -> GraphImpactResponse:
"""Compute impact radius from a target node."""
_ = project_id
return await graph_service.impact(request)
@router.get("/health", response_model=GraphHealthResponse)
async def graph_health(
graph_service: GraphIntelligenceServiceV2ExternalDep,
project_id: ProjectExternalIdPathDep,
scope: str | None = Query(default=None),
timeframe: str | None = Query(default=None),
) -> GraphHealthResponse:
"""Report graph quality metrics and issue candidates."""
_ = project_id
return await graph_service.health(scope=scope, timeframe=timeframe)
@router.post("/reindex", response_model=GraphReindexResponse)
async def graph_reindex(
request: GraphReindexRequest,
graph_service: GraphIntelligenceServiceV2ExternalDep,
task_scheduler: TaskSchedulerDep,
project_id: ProjectExternalIdPathDep,
) -> GraphReindexResponse:
"""Queue a graph reindex operation for the current project."""
task_scheduler.schedule(
"reindex_graph_project",
project_id=project_id,
mode=request.mode,
reason=request.reason,
)
return await graph_service.start_reindex_job()
-384
View File
@@ -16,13 +16,6 @@ from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
from basic_memory.mcp.tools import build_context as mcp_build_context
from basic_memory.mcp.tools import edit_note as mcp_edit_note
from basic_memory.mcp.tools import fcm_export_model as mcp_fcm_export_model
from basic_memory.mcp.tools import fcm_import_model as mcp_fcm_import_model
from basic_memory.mcp.tools import fcm_rank_actions as mcp_fcm_rank_actions
from basic_memory.mcp.tools import fcm_simulate as mcp_fcm_simulate
from basic_memory.mcp.tools import graph_health as mcp_graph_health
from basic_memory.mcp.tools import graph_impact as mcp_graph_impact
from basic_memory.mcp.tools import graph_lineage as mcp_graph_lineage
from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects
from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces
from basic_memory.mcp.tools import read_note as mcp_read_note
@@ -47,17 +40,6 @@ def _print_json(result: Any) -> None:
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
def _parse_json_option(raw_value: Optional[str], option_name: str) -> Any:
"""Parse a JSON CLI option with deterministic error handling."""
if raw_value is None:
return None
try:
return json.loads(raw_value)
except json.JSONDecodeError as exc:
typer.echo(f"Invalid JSON for {option_name}: {exc}", err=True)
raise typer.Exit(1)
# --- Commands ---
@@ -384,372 +366,6 @@ def recent_activity(
raise
@tool_app.command("graph-lineage")
def graph_lineage(
start: Annotated[str, typer.Argument(help="Start node identifier or memory:// reference")],
goal: Annotated[
Optional[str],
typer.Option("--goal", help="Optional goal node identifier for targeted lineage"),
] = None,
max_hops: int = typer.Option(4, "--max-hops", help="Maximum traversal hops (1-6)"),
relation_filters: Annotated[
Optional[List[str]],
typer.Option("--relation-filter", help="Relation filters (repeatable)"),
] = None,
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Get graph lineage paths from a start node."""
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_graph_lineage(
start=start,
goal=goal,
max_hops=max_hops,
relation_filters=relation_filters or [],
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during graph_lineage: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("graph-impact")
def graph_impact(
target: Annotated[str, typer.Argument(help="Target node identifier or memory:// reference")],
horizon: int = typer.Option(2, "--horizon", help="Impact horizon in hops (1-4)"),
relation_filters: Annotated[
Optional[List[str]],
typer.Option("--relation-filter", help="Relation filters (repeatable)"),
] = None,
include_reasons: bool = typer.Option(
True,
"--include-reasons/--no-include-reasons",
help="Include reason strings in impact output",
),
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Get impact radius for a target node."""
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_graph_impact(
target=target,
horizon=horizon,
relation_filters=relation_filters or [],
include_reasons=include_reasons,
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during graph_impact: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("graph-health")
def graph_health(
scope: Annotated[Optional[str], typer.Option("--scope", help="Optional scope prefix")] = None,
timeframe: Annotated[
Optional[str], typer.Option("--timeframe", help="Optional timeframe filter")
] = None,
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Get graph health metrics and issue candidates."""
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_graph_health(
scope=scope,
timeframe=timeframe,
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during graph_health: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("fcm-simulate")
def fcm_simulate(
actions_json: Annotated[
str,
typer.Option(
"--actions-json",
help='JSON array of actions, e.g. [{"node_id":"n1","delta":0.2}]',
),
],
scenario_json: Annotated[
Optional[str],
typer.Option("--scenario-json", help="Optional JSON scenario object"),
] = None,
clamp_rules_json: Annotated[
Optional[str],
typer.Option("--clamp-rules-json", help="Optional JSON array of clamp rules"),
] = None,
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Run an FCM simulation."""
actions = _parse_json_option(actions_json, "--actions-json")
scenario = _parse_json_option(scenario_json, "--scenario-json")
clamp_rules = _parse_json_option(clamp_rules_json, "--clamp-rules-json")
if not isinstance(actions, list):
typer.echo("Invalid JSON for --actions-json: expected a JSON array", err=True)
raise typer.Exit(1)
if scenario is not None and not isinstance(scenario, dict):
typer.echo("Invalid JSON for --scenario-json: expected a JSON object", err=True)
raise typer.Exit(1)
if clamp_rules is not None and not isinstance(clamp_rules, list):
typer.echo("Invalid JSON for --clamp-rules-json: expected a JSON array", err=True)
raise typer.Exit(1)
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_fcm_simulate(
actions=actions,
scenario=scenario,
clamp_rules=clamp_rules,
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during fcm_simulate: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("fcm-rank-actions")
def fcm_rank_actions(
goal: Annotated[str, typer.Argument(help="Goal node identifier")],
constraints_json: Annotated[
Optional[str],
typer.Option("--constraints-json", help="Optional JSON object of ranking constraints"),
] = None,
top_k: int = typer.Option(10, "--top-k", help="Number of recommendations to return"),
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Rank intervention actions for an FCM goal."""
constraints = _parse_json_option(constraints_json, "--constraints-json")
if constraints is not None and not isinstance(constraints, dict):
typer.echo("Invalid JSON for --constraints-json: expected a JSON object", err=True)
raise typer.Exit(1)
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_fcm_rank_actions(
goal=goal,
constraints=constraints,
top_k=top_k,
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during fcm_rank_actions: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("fcm-import-model")
def fcm_import_model(
source: Annotated[str, typer.Argument(help="Source path or URI for import payload")],
format: Annotated[
str,
typer.Option("--format", help="Import format (currently csv_bundle_v1)"),
] = "csv_bundle_v1",
merge_mode: Annotated[
str,
typer.Option("--merge-mode", help="Merge strategy: replace or upsert"),
] = "upsert",
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Import an FCM model."""
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_fcm_import_model(
source=source,
format=format, # pyright: ignore[reportArgumentType]
merge_mode=merge_mode, # pyright: ignore[reportArgumentType]
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during fcm_import_model: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("fcm-export-model")
def fcm_export_model(
format: Annotated[
str,
typer.Option("--format", help="Export format (currently csv_bundle_v1)"),
] = "csv_bundle_v1",
selection_json: Annotated[
Optional[str],
typer.Option("--selection-json", help="Optional JSON object selection payload"),
] = None,
project: Annotated[
Optional[str],
typer.Option(help="The project to use. If not provided, the default project will be used."),
] = None,
workspace: Annotated[
Optional[str],
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
] = None,
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Export an FCM model."""
selection = _parse_json_option(selection_json, "--selection-json")
if selection is not None and not isinstance(selection, dict):
typer.echo("Invalid JSON for --selection-json: expected a JSON object", err=True)
raise typer.Exit(1)
try:
validate_routing_flags(local, cloud)
with force_routing(local=local, cloud=cloud):
result = run_with_cleanup(
mcp_fcm_export_model(
format=format, # pyright: ignore[reportArgumentType]
selection=selection,
project=project,
workspace=workspace,
output_format="json",
)
)
_print_json(result)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during fcm_export_model: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command("search-notes")
def search_notes(
query: Annotated[
-8
View File
@@ -131,10 +131,6 @@ from basic_memory.deps.services import (
DirectoryServiceV2Dep,
get_directory_service_v2_external,
DirectoryServiceV2ExternalDep,
get_graph_intelligence_service_v2_external,
GraphIntelligenceServiceV2ExternalDep,
get_fcm_service_v2_external,
FCMServiceV2ExternalDep,
)
from basic_memory.deps.importers import (
@@ -273,10 +269,6 @@ __all__ = [
"DirectoryServiceV2Dep",
"get_directory_service_v2_external",
"DirectoryServiceV2ExternalDep",
"get_graph_intelligence_service_v2_external",
"GraphIntelligenceServiceV2ExternalDep",
"get_fcm_service_v2_external",
"FCMServiceV2ExternalDep",
# Importers
"get_chatgpt_importer",
"ChatGPTImporterDep",
-44
View File
@@ -39,8 +39,6 @@ from basic_memory.deps.repositories import (
from basic_memory.markdown import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.services import EntityService, ProjectService
from basic_memory.services.fcm_service import FCMService
from basic_memory.services.graph_intelligence_service import GraphIntelligenceService
from basic_memory.services.context_service import ContextService
from basic_memory.services.directory_service import DirectoryService
from basic_memory.services.file_service import FileService
@@ -360,30 +358,6 @@ async def get_context_service_v2_external(
ContextServiceV2ExternalDep = Annotated[ContextService, Depends(get_context_service_v2_external)]
# --- Graph Intelligence Service ---
async def get_graph_intelligence_service_v2_external() -> GraphIntelligenceService:
"""Create GraphIntelligenceService for v2 API (uses external_id routing)."""
return GraphIntelligenceService()
GraphIntelligenceServiceV2ExternalDep = Annotated[
GraphIntelligenceService, Depends(get_graph_intelligence_service_v2_external)
]
# --- FCM Service ---
async def get_fcm_service_v2_external() -> FCMService:
"""Create FCMService for v2 API (uses external_id routing)."""
return FCMService()
FCMServiceV2ExternalDep = Annotated[FCMService, Depends(get_fcm_service_v2_external)]
# --- Sync Service ---
@@ -561,21 +535,6 @@ async def get_task_scheduler(
async def _reindex_project(**_: Any) -> None:
await search_service.reindex_all()
async def _sync_graph_entity(entity_id: int, **extra_payload: Any) -> None:
# Trigger: graph-entity sync task is scheduled from graph lifecycle hooks.
# Why: keep scheduler contract stable while graph index provider work lands in later phases.
# Outcome: no-op in phase 1; task name remains valid for API and tool contracts.
del entity_id, extra_payload
async def _sync_graph_project(force_full: bool = False, **_: Any) -> None:
await _sync_project(force_full=force_full)
async def _reindex_graph_project(**_: Any) -> None:
# Trigger: graph reindex requested.
# Why: phase 1 has no dedicated graph index worker yet.
# Outcome: run project sync path so writes stay coherent while graph provider ships.
await _sync_project(force_full=True)
scheduler = LocalTaskScheduler(
{
"reindex_entity": _reindex_entity,
@@ -583,9 +542,6 @@ async def get_task_scheduler(
"sync_entity_vectors": _sync_entity_vectors,
"sync_project": _sync_project,
"reindex_project": _reindex_project,
"sync_graph_entity": _sync_graph_entity,
"sync_graph_project": _sync_graph_project,
"reindex_graph_project": _reindex_graph_project,
},
test_mode=app_config.is_test_env,
)
-4
View File
@@ -18,8 +18,6 @@ from basic_memory.mcp.clients.directory import DirectoryClient
from basic_memory.mcp.clients.resource import ResourceClient
from basic_memory.mcp.clients.project import ProjectClient
from basic_memory.mcp.clients.schema import SchemaClient
from basic_memory.mcp.clients.graph import GraphClient
from basic_memory.mcp.clients.fcm import FCMClient
__all__ = [
"KnowledgeClient",
@@ -29,6 +27,4 @@ __all__ = [
"ResourceClient",
"ProjectClient",
"SchemaClient",
"GraphClient",
"FCMClient",
]
-56
View File
@@ -1,56 +0,0 @@
"""Typed client for FCM API operations."""
from httpx import AsyncClient
from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.graph_intelligence import (
FCMExportRequest,
FCMExportResponse,
FCMImportRequest,
FCMImportResponse,
FCMRankActionsRequest,
FCMRankActionsResponse,
FCMSimulateRequest,
FCMSimulateResponse,
)
class FCMClient:
"""Typed client for FCM operations."""
def __init__(self, http_client: AsyncClient, project_id: str):
self.http_client = http_client
self.project_id = project_id
self._base_path = f"/v2/projects/{project_id}/fcm"
async def simulate(self, request: FCMSimulateRequest) -> FCMSimulateResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/simulate",
json=request.model_dump(mode="json"),
)
return FCMSimulateResponse.model_validate(response.json())
async def rank_actions(self, request: FCMRankActionsRequest) -> FCMRankActionsResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/rank-actions",
json=request.model_dump(mode="json"),
)
return FCMRankActionsResponse.model_validate(response.json())
async def import_model(self, request: FCMImportRequest) -> FCMImportResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/import",
json=request.model_dump(mode="json"),
)
return FCMImportResponse.model_validate(response.json())
async def export_model(self, request: FCMExportRequest) -> FCMExportResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/export",
json=request.model_dump(mode="json"),
)
return FCMExportResponse.model_validate(response.json())
-62
View File
@@ -1,62 +0,0 @@
"""Typed client for graph intelligence API operations."""
from httpx import AsyncClient
from basic_memory.mcp.tools.utils import call_get, call_post
from basic_memory.schemas.graph_intelligence import (
GraphHealthResponse,
GraphImpactRequest,
GraphImpactResponse,
GraphLineageRequest,
GraphLineageResponse,
GraphReindexRequest,
GraphReindexResponse,
)
class GraphClient:
"""Typed client for graph intelligence operations."""
def __init__(self, http_client: AsyncClient, project_id: str):
self.http_client = http_client
self.project_id = project_id
self._base_path = f"/v2/projects/{project_id}/graph"
async def lineage(self, request: GraphLineageRequest) -> GraphLineageResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/lineage",
json=request.model_dump(mode="json"),
)
return GraphLineageResponse.model_validate(response.json())
async def impact(self, request: GraphImpactRequest) -> GraphImpactResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/impact",
json=request.model_dump(mode="json"),
)
return GraphImpactResponse.model_validate(response.json())
async def health(
self, scope: str | None = None, timeframe: str | None = None
) -> GraphHealthResponse:
params: dict[str, str] = {}
if scope is not None:
params["scope"] = scope
if timeframe is not None:
params["timeframe"] = timeframe
response = await call_get(
self.http_client,
f"{self._base_path}/health",
params=params,
)
return GraphHealthResponse.model_validate(response.json())
async def reindex(self, request: GraphReindexRequest) -> GraphReindexResponse:
response = await call_post(
self.http_client,
f"{self._base_path}/reindex",
json=request.model_dump(mode="json"),
)
return GraphReindexResponse.model_validate(response.json())
-18
View File
@@ -24,16 +24,6 @@ from basic_memory.mcp.tools.list_directory import list_directory
from basic_memory.mcp.tools.edit_note import edit_note
from basic_memory.mcp.tools.move_note import move_note
from basic_memory.mcp.tools.workspaces import list_workspaces
from basic_memory.mcp.tools.graph_intelligence import (
graph_lineage,
graph_impact,
graph_health,
graph_reindex,
fcm_simulate,
fcm_rank_actions,
fcm_import_model,
fcm_export_model,
)
from basic_memory.mcp.tools.project_management import (
list_memory_projects,
create_memory_project,
@@ -54,15 +44,7 @@ __all__ = [
"delete_note",
"delete_project",
"edit_note",
"fcm_export_model",
"fcm_import_model",
"fcm_rank_actions",
"fcm_simulate",
"fetch",
"graph_health",
"graph_impact",
"graph_lineage",
"graph_reindex",
"list_directory",
"list_memory_projects",
"list_workspaces",
@@ -1,271 +0,0 @@
"""MCP tools for graph intelligence and FCM contracts."""
from typing import Any, Literal
from fastmcp import Context
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.server import mcp
from basic_memory.schemas.graph_intelligence import (
FCMExportRequest,
FCMImportRequest,
FCMRankActionsRequest,
FCMSimulateRequest,
GraphImpactRequest,
GraphLineageRequest,
GraphReindexRequest,
)
def _format_lineage_text(result: dict[str, Any]) -> str:
root = result["root"]["title"]
path_count = len(result.get("paths", []))
return f"# Graph Lineage\n\nRoot: {root}\nPaths: {path_count}"
def _format_impact_text(result: dict[str, Any]) -> str:
target = result["target"]["title"]
affected = len(result.get("affected", []))
return f"# Graph Impact\n\nTarget: {target}\nAffected: {affected}"
def _format_health_text(result: dict[str, Any]) -> str:
metrics = result["metrics"]
return (
"# Graph Health\n\n"
f"- orphan_rate: {metrics['orphan_rate']}\n"
f"- stale_central_nodes: {metrics['stale_central_nodes']}\n"
f"- overloaded_hubs: {metrics['overloaded_hubs']}\n"
f"- contradiction_candidates: {metrics['contradiction_candidates']}"
)
def _format_fcm_simulate_text(result: dict[str, Any]) -> str:
deltas = len(result.get("deltas", []))
converged = result["stability"]["converged"]
return f"# FCM Simulation\n\nDeltas: {deltas}\nConverged: {converged}"
def _format_fcm_rank_text(result: dict[str, Any]) -> str:
goal = result["goal"]["label"]
count = len(result.get("recommendations", []))
return f"# FCM Action Ranking\n\nGoal: {goal}\nRecommendations: {count}"
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def graph_lineage(
start: str,
goal: str | None = None,
max_hops: int = 4,
relation_filters: list[str] | None = None,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Get lineage paths from a start node toward an optional goal."""
from basic_memory.mcp.clients import GraphClient
request = GraphLineageRequest(
start=start,
goal=goal,
max_hops=max_hops,
relation_filters=relation_filters or [],
)
async with get_project_client(project, workspace, context) as (client, active_project):
graph_client = GraphClient(client, active_project.external_id)
result = await graph_client.lineage(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return _format_lineage_text(payload)
return payload
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def graph_impact(
target: str,
horizon: int,
relation_filters: list[str] | None = None,
include_reasons: bool = True,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Get impact radius from a target node."""
from basic_memory.mcp.clients import GraphClient
request = GraphImpactRequest(
target=target,
horizon=horizon,
relation_filters=relation_filters or [],
include_reasons=include_reasons,
)
async with get_project_client(project, workspace, context) as (client, active_project):
graph_client = GraphClient(client, active_project.external_id)
result = await graph_client.impact(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return _format_impact_text(payload)
return payload
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def graph_health(
scope: str | None = None,
timeframe: str | None = None,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Get graph health metrics and issues."""
from basic_memory.mcp.clients import GraphClient
async with get_project_client(project, workspace, context) as (client, active_project):
graph_client = GraphClient(client, active_project.external_id)
result = await graph_client.health(scope=scope, timeframe=timeframe)
payload = result.model_dump(mode="json")
if output_format == "text":
return _format_health_text(payload)
return payload
@mcp.tool(annotations={"readOnlyHint": False, "openWorldHint": False})
async def fcm_simulate(
actions: list[dict[str, Any]],
scenario: dict[str, Any] | None = None,
clamp_rules: list[dict[str, Any]] | None = None,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Run an FCM simulation with optional scenario controls."""
from basic_memory.mcp.clients import FCMClient
request = FCMSimulateRequest.model_validate(
{
"actions": actions,
"scenario": scenario or {},
"clamp_rules": clamp_rules or [],
}
)
async with get_project_client(project, workspace, context) as (client, active_project):
fcm_client = FCMClient(client, active_project.external_id)
result = await fcm_client.simulate(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return _format_fcm_simulate_text(payload)
return payload
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def fcm_rank_actions(
goal: str,
constraints: dict[str, Any] | None = None,
top_k: int = 10,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Rank intervention actions for an FCM goal node."""
from basic_memory.mcp.clients import FCMClient
request = FCMRankActionsRequest.model_validate(
{
"goal": goal,
"constraints": constraints or {},
"top_k": top_k,
}
)
async with get_project_client(project, workspace, context) as (client, active_project):
fcm_client = FCMClient(client, active_project.external_id)
result = await fcm_client.rank_actions(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return _format_fcm_rank_text(payload)
return payload
@mcp.tool(annotations={"readOnlyHint": False, "openWorldHint": False})
async def fcm_import_model(
source: str,
format: Literal["csv_bundle_v1"] = "csv_bundle_v1",
merge_mode: Literal["replace", "upsert"] = "upsert",
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Import an FCM model from an external source."""
from basic_memory.mcp.clients import FCMClient
request = FCMImportRequest(source=source, format=format, merge_mode=merge_mode)
async with get_project_client(project, workspace, context) as (client, active_project):
fcm_client = FCMClient(client, active_project.external_id)
result = await fcm_client.import_model(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return (
"# FCM Import\n\n"
f"Import ID: {payload['import_id']}\n"
f"Nodes Loaded: {payload['nodes_loaded']}\n"
f"Edges Loaded: {payload['edges_loaded']}"
)
return payload
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def fcm_export_model(
format: Literal["csv_bundle_v1"] = "csv_bundle_v1",
selection: dict[str, Any] | None = None,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Export an FCM model selection."""
from basic_memory.mcp.clients import FCMClient
request = FCMExportRequest.model_validate(
{
"format": format,
"selection": selection or {},
}
)
async with get_project_client(project, workspace, context) as (client, active_project):
fcm_client = FCMClient(client, active_project.external_id)
result = await fcm_client.export_model(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return (
"# FCM Export\n\n"
f"Export ID: {payload['export_id']}\n"
f"Node Count: {payload['node_count']}\n"
f"Edge Count: {payload['edge_count']}"
)
return payload
@mcp.tool(annotations={"readOnlyHint": False, "openWorldHint": False})
async def graph_reindex(
mode: Literal["full", "incremental"] = "incremental",
reason: str | None = None,
project: str | None = None,
workspace: str | None = None,
output_format: Literal["json", "text"] = "json",
context: Context | None = None,
) -> dict[str, Any] | str:
"""Queue a graph reindex for the active project."""
from basic_memory.mcp.clients import GraphClient
request = GraphReindexRequest(mode=mode, reason=reason)
async with get_project_client(project, workspace, context) as (client, active_project):
graph_client = GraphClient(client, active_project.external_id)
result = await graph_client.reindex(request)
payload = result.model_dump(mode="json")
if output_format == "text":
return f"# Graph Reindex\n\nJob ID: {payload['job_id']}\nStatus: {payload['status']}"
return payload
@@ -1,318 +0,0 @@
"""Schemas for Local+ graph intelligence and FCM contracts."""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
# --- Graph contracts ---
class GraphLineageRequest(BaseModel):
"""Request contract for graph lineage queries."""
start: str
goal: str | None = None
max_hops: int = Field(default=4, ge=1, le=6)
relation_filters: list[str] = Field(default_factory=list)
class GraphNodeRef(BaseModel):
"""Minimal graph node descriptor."""
id: str
title: str
permalink: str | None = None
class GraphPathEdge(BaseModel):
"""Edge descriptor for lineage paths."""
relation: str
direction: Literal["outgoing", "incoming"]
class GraphLineagePath(BaseModel):
"""Single lineage path with scores and provenance."""
path_id: str
nodes: list[GraphNodeRef] = Field(default_factory=list)
edges: list[GraphPathEdge] = Field(default_factory=list)
deterministic_path_score: float
confidence: float
evidence_refs: list[str] = Field(default_factory=list)
class GraphLineageResponse(BaseModel):
"""Response contract for graph lineage queries."""
root: GraphNodeRef
paths: list[GraphLineagePath] = Field(default_factory=list)
generated_at: datetime
class GraphImpactRequest(BaseModel):
"""Request contract for impact-radius queries."""
target: str
horizon: int = Field(ge=1, le=4)
relation_filters: list[str] = Field(default_factory=list)
include_reasons: bool = True
class GraphImpactTarget(BaseModel):
"""Impact response target descriptor."""
id: str
title: str
class GraphImpactItem(BaseModel):
"""Affected node entry for impact responses."""
id: str
title: str
distance: int
impact_score: float
confidence: float
reasons: list[str] = Field(default_factory=list)
evidence_refs: list[str] = Field(default_factory=list)
class GraphImpactSummary(BaseModel):
"""Summary counters for impact responses."""
total_considered: int
total_returned: int
class GraphImpactResponse(BaseModel):
"""Response contract for impact-radius queries."""
target: GraphImpactTarget
affected: list[GraphImpactItem] = Field(default_factory=list)
summary: GraphImpactSummary
class GraphHealthMetrics(BaseModel):
"""Top-level graph health metrics."""
orphan_rate: float
stale_central_nodes: int
overloaded_hubs: int
contradiction_candidates: int
class GraphHealthIssue(BaseModel):
"""Actionable graph-health issue entry."""
issue_type: Literal[
"orphan",
"stale_central",
"overloaded_hub",
"contradiction_candidate",
]
entity_id: str
severity: Literal["low", "medium", "high"]
reason: str
suggested_action: str
confidence: float | None = None
class GraphHealthResponse(BaseModel):
"""Response contract for health checks."""
metrics: GraphHealthMetrics
issues: list[GraphHealthIssue] = Field(default_factory=list)
computed_at: datetime
class GraphReindexRequest(BaseModel):
"""Request contract for graph reindex scheduling."""
mode: Literal["full", "incremental"] = "incremental"
reason: str | None = None
class GraphReindexResponse(BaseModel):
"""Response contract for graph reindex scheduling."""
job_id: str
status: Literal["queued", "running", "completed", "failed"]
scheduled_at: datetime
# --- FCM contracts ---
class FCMAction(BaseModel):
"""Action delta for simulation input."""
node_id: str
delta: float
class FCMScenario(BaseModel):
"""Simulation runtime configuration."""
steps: int = 12
activation: Literal["tanh", "sigmoid", "bounded_linear"] = "tanh"
decay: float = 0.05
class FCMClampRule(BaseModel):
"""Clamp bounds for selected nodes."""
node_id: str
min: float
max: float
class FCMSimulateRequest(BaseModel):
"""Request contract for FCM simulation."""
actions: list[FCMAction]
scenario: FCMScenario = Field(default_factory=FCMScenario)
clamp_rules: list[FCMClampRule] = Field(default_factory=list)
class FCMNodeState(BaseModel):
"""Node state in baseline/projected vectors."""
node_id: str
state: float
class FCMNodeDelta(BaseModel):
"""Node delta entry in simulation output."""
node_id: str
delta: float
class FCMStability(BaseModel):
"""Simulation stability metadata."""
converged: bool
iterations_used: int
residual: float
class FCMInfluencer(BaseModel):
"""Top influencer entry for explanation payload."""
source: str
weight: float
class FCMExplanation(BaseModel):
"""Per-node explanation payload."""
node_id: str
top_influencers: list[FCMInfluencer] = Field(default_factory=list)
class FCMSimulateResponse(BaseModel):
"""Response contract for FCM simulation."""
baseline: list[FCMNodeState] = Field(default_factory=list)
projected: list[FCMNodeState] = Field(default_factory=list)
deltas: list[FCMNodeDelta] = Field(default_factory=list)
stability: FCMStability
confidence: float
explanations: list[FCMExplanation] = Field(default_factory=list)
evidence_refs: list[str] = Field(default_factory=list)
class FCMRankConstraints(BaseModel):
"""Constraint set for action ranking."""
max_negative_impact: float | None = None
required_tags: list[str] = Field(default_factory=list)
disallowed_nodes: list[str] = Field(default_factory=list)
class FCMRankActionsRequest(BaseModel):
"""Request contract for FCM action ranking."""
goal: str
constraints: FCMRankConstraints = Field(default_factory=FCMRankConstraints)
top_k: int = Field(default=10, ge=1, le=25)
class FCMGoalRef(BaseModel):
"""Goal descriptor for ranking output."""
node_id: str
label: str
class FCMRecommendation(BaseModel):
"""Ranked intervention candidate."""
action_node_id: str
expected_goal_delta: float
risk_penalty: float
net_score: float
confidence: float
rationale: list[str] = Field(default_factory=list)
evidence_refs: list[str] = Field(default_factory=list)
class FCMRankActionsResponse(BaseModel):
"""Response contract for action ranking."""
goal: FCMGoalRef
recommendations: list[FCMRecommendation] = Field(default_factory=list)
class FCMImportRequest(BaseModel):
"""Request contract for model import."""
source: str
format: Literal["csv_bundle_v1"] = "csv_bundle_v1"
merge_mode: Literal["replace", "upsert"] = "upsert"
class FCMImportResponse(BaseModel):
"""Response contract for model import."""
import_id: str
nodes_loaded: int
edges_loaded: int
warnings: list[str] = Field(default_factory=list)
errors: list[str] = Field(default_factory=list)
class FCMExportSelection(BaseModel):
"""Scope selection for model export."""
scope: Literal["all", "tag", "subgraph"] = "all"
tag: str | None = None
seed_nodes: list[str] = Field(default_factory=list)
class FCMExportRequest(BaseModel):
"""Request contract for model export."""
format: Literal["csv_bundle_v1"] = "csv_bundle_v1"
selection: FCMExportSelection = Field(default_factory=FCMExportSelection)
class FCMExportFile(BaseModel):
"""Single file descriptor in an export response."""
name: str
path: str
class FCMExportResponse(BaseModel):
"""Response contract for model export."""
export_id: str
format: Literal["csv_bundle_v1"]
files: list[FCMExportFile] = Field(default_factory=list)
node_count: int
edge_count: int
metadata: dict[str, Any] | None = None
-96
View File
@@ -1,96 +0,0 @@
"""Service layer for FCM contract endpoints."""
from uuid import uuid4
from basic_memory.schemas.graph_intelligence import (
FCMExportFile,
FCMExportRequest,
FCMExportResponse,
FCMGoalRef,
FCMImportRequest,
FCMImportResponse,
FCMNodeDelta,
FCMNodeState,
FCMRankActionsRequest,
FCMRankActionsResponse,
FCMRecommendation,
FCMSimulateRequest,
FCMSimulateResponse,
FCMStability,
)
class FCMService:
"""FCM contract service.
Phase 1 keeps deterministic behavior so API and tool surfaces stabilize
before introducing advanced simulation engines.
"""
async def simulate(self, request: FCMSimulateRequest) -> FCMSimulateResponse:
"""Return deterministic baseline/projected state vectors."""
baseline = [FCMNodeState(node_id=action.node_id, state=0.0) for action in request.actions]
projected = [
FCMNodeState(node_id=action.node_id, state=action.delta) for action in request.actions
]
deltas = [
FCMNodeDelta(node_id=action.node_id, delta=action.delta) for action in request.actions
]
return FCMSimulateResponse(
baseline=baseline,
projected=projected,
deltas=deltas,
stability=FCMStability(
converged=True,
iterations_used=min(request.scenario.steps, 5),
residual=0.0,
),
confidence=0.5,
explanations=[],
evidence_refs=[],
)
async def rank_actions(self, request: FCMRankActionsRequest) -> FCMRankActionsResponse:
"""Return deterministic ranked actions for a target goal."""
recommendations = [
FCMRecommendation(
action_node_id=f"{request.goal}:action:{idx + 1}",
expected_goal_delta=0.25 - (idx * 0.01),
risk_penalty=0.05 + (idx * 0.005),
net_score=0.20 - (idx * 0.015),
confidence=0.5,
rationale=["Contract skeleton recommendation"],
evidence_refs=[],
)
for idx in range(min(request.top_k, 3))
]
return FCMRankActionsResponse(
goal=FCMGoalRef(node_id=request.goal, label=request.goal),
recommendations=recommendations,
)
async def import_model(self, request: FCMImportRequest) -> FCMImportResponse:
"""Return deterministic import metadata."""
_ = request
return FCMImportResponse(
import_id=str(uuid4()),
nodes_loaded=0,
edges_loaded=0,
warnings=[],
errors=[],
)
async def export_model(self, request: FCMExportRequest) -> FCMExportResponse:
"""Return deterministic export metadata and file descriptors."""
scope = request.selection.scope
return FCMExportResponse(
export_id=str(uuid4()),
format=request.format,
files=[
FCMExportFile(name="nodes.csv", path=f"/tmp/{scope}-nodes.csv"),
FCMExportFile(name="edges.csv", path=f"/tmp/{scope}-edges.csv"),
],
node_count=0,
edge_count=0,
metadata={"scope": scope},
)
@@ -1,122 +0,0 @@
"""Service layer for graph intelligence contract endpoints."""
from datetime import datetime, timezone
from uuid import uuid4
from basic_memory.schemas.graph_intelligence import (
GraphHealthMetrics,
GraphHealthResponse,
GraphImpactItem,
GraphImpactRequest,
GraphImpactResponse,
GraphImpactSummary,
GraphImpactTarget,
GraphLineagePath,
GraphLineageRequest,
GraphLineageResponse,
GraphNodeRef,
GraphPathEdge,
GraphReindexResponse,
)
def _normalize_memory_ref(value: str) -> str:
"""Normalize user input into a memory:// reference string."""
if value.startswith("memory://"):
return value
return f"memory://{value}"
def _normalize_node_id(value: str) -> str:
"""Return a stable node id for contract skeleton outputs."""
return value.removeprefix("memory://")
class GraphIntelligenceService:
"""Graph intelligence contract service.
Phase 1 behavior is intentionally deterministic and lightweight so routing,
clients, and contract tests can ship before deeper traversal engines.
"""
async def lineage(self, request: GraphLineageRequest) -> GraphLineageResponse:
"""Return a deterministic lineage payload for the requested root/goal."""
root_ref = _normalize_memory_ref(request.start)
root = GraphNodeRef(
id=_normalize_node_id(root_ref),
title=_normalize_node_id(root_ref),
permalink=_normalize_node_id(root_ref),
)
nodes = [root]
edges: list[GraphPathEdge] = []
if request.goal:
goal_ref = _normalize_memory_ref(request.goal)
nodes.append(
GraphNodeRef(
id=_normalize_node_id(goal_ref),
title=_normalize_node_id(goal_ref),
permalink=_normalize_node_id(goal_ref),
)
)
edges.append(GraphPathEdge(relation="related_to", direction="outgoing"))
path = GraphLineagePath(
path_id=f"path-{uuid4()}",
nodes=nodes,
edges=edges,
deterministic_path_score=1.0 if request.goal else 0.5,
confidence=0.5,
evidence_refs=[root_ref],
)
return GraphLineageResponse(
root=root,
paths=[path],
generated_at=datetime.now(timezone.utc),
)
async def impact(self, request: GraphImpactRequest) -> GraphImpactResponse:
"""Return a deterministic impact preview payload."""
target_id = _normalize_node_id(_normalize_memory_ref(request.target))
affected = [
GraphImpactItem(
id=f"{target_id}:neighbor:1",
title=f"{target_id} dependent",
distance=min(request.horizon, 1),
impact_score=0.55,
confidence=0.5,
reasons=["Connected via typed relation in contract skeleton"],
evidence_refs=[_normalize_memory_ref(request.target)],
)
]
if not request.include_reasons:
affected[0].reasons = []
return GraphImpactResponse(
target=GraphImpactTarget(id=target_id, title=target_id),
affected=affected,
summary=GraphImpactSummary(total_considered=1, total_returned=1),
)
async def health(self, scope: str | None, timeframe: str | None) -> GraphHealthResponse:
"""Return deterministic baseline health metrics."""
_ = (scope, timeframe)
return GraphHealthResponse(
metrics=GraphHealthMetrics(
orphan_rate=0.0,
stale_central_nodes=0,
overloaded_hubs=0,
contradiction_candidates=0,
),
issues=[],
computed_at=datetime.now(timezone.utc),
)
async def start_reindex_job(self) -> GraphReindexResponse:
"""Create reindex job metadata for queued responses."""
return GraphReindexResponse(
job_id=str(uuid4()),
status="queued",
scheduled_at=datetime.now(timezone.utc),
)
@@ -1,120 +0,0 @@
"""Tests for v2 graph intelligence and FCM routers."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_graph_lineage_contract(client: AsyncClient, v2_project_url: str):
response = await client.post(
f"{v2_project_url}/graph/lineage",
json={"start": "memory://specs/search"},
)
assert response.status_code == 200
data = response.json()
assert set(["root", "paths", "generated_at"]).issubset(data.keys())
assert data["root"]["id"] == "specs/search"
assert isinstance(data["paths"], list)
@pytest.mark.asyncio
async def test_graph_impact_contract(client: AsyncClient, v2_project_url: str):
response = await client.post(
f"{v2_project_url}/graph/impact",
json={"target": "memory://specs/search", "horizon": 2},
)
assert response.status_code == 200
data = response.json()
assert set(["target", "affected", "summary"]).issubset(data.keys())
assert data["summary"]["total_considered"] >= data["summary"]["total_returned"]
@pytest.mark.asyncio
async def test_graph_health_contract(client: AsyncClient, v2_project_url: str):
response = await client.get(
f"{v2_project_url}/graph/health",
params={"scope": "specs", "timeframe": "30d"},
)
assert response.status_code == 200
data = response.json()
assert set(["metrics", "issues", "computed_at"]).issubset(data.keys())
assert "orphan_rate" in data["metrics"]
@pytest.mark.asyncio
async def test_graph_reindex_schedules_task(
client: AsyncClient,
v2_project_url: str,
task_scheduler_spy: list[dict[str, object]],
):
response = await client.post(
f"{v2_project_url}/graph/reindex",
json={"mode": "full", "reason": "contract test"},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "queued"
assert data["job_id"]
assert task_scheduler_spy
last = task_scheduler_spy[-1]
assert last["task_name"] == "reindex_graph_project"
assert last["payload"]["mode"] == "full"
assert last["payload"]["reason"] == "contract test"
@pytest.mark.asyncio
async def test_fcm_simulate_contract(client: AsyncClient, v2_project_url: str):
response = await client.post(
f"{v2_project_url}/fcm/simulate",
json={"actions": [{"node_id": "test-node", "delta": 0.2}]},
)
assert response.status_code == 200
data = response.json()
assert set(["baseline", "projected", "deltas", "stability", "confidence"]).issubset(data.keys())
assert data["stability"]["converged"] is True
@pytest.mark.asyncio
async def test_fcm_rank_actions_contract(client: AsyncClient, v2_project_url: str):
response = await client.post(
f"{v2_project_url}/fcm/rank-actions",
json={"goal": "reduce-regressions", "top_k": 2},
)
assert response.status_code == 200
data = response.json()
assert set(["goal", "recommendations"]).issubset(data.keys())
assert len(data["recommendations"]) <= 2
@pytest.mark.asyncio
async def test_fcm_import_contract(client: AsyncClient, v2_project_url: str):
response = await client.post(
f"{v2_project_url}/fcm/import",
json={"source": "/tmp/model.csv", "format": "csv_bundle_v1"},
)
assert response.status_code == 200
data = response.json()
assert set(["import_id", "nodes_loaded", "edges_loaded", "warnings", "errors"]).issubset(
data.keys()
)
@pytest.mark.asyncio
async def test_fcm_export_contract(client: AsyncClient, v2_project_url: str):
response = await client.post(
f"{v2_project_url}/fcm/export",
json={"format": "csv_bundle_v1", "selection": {"scope": "all"}},
)
assert response.status_code == 200
data = response.json()
assert set(["export_id", "format", "files", "node_count", "edge_count"]).issubset(data.keys())
assert len(data["files"]) == 2
@@ -1,184 +0,0 @@
"""Tests for graph/FCM CLI tool JSON passthrough commands."""
import json
from unittest.mock import AsyncMock, patch
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
@patch(
"basic_memory.cli.commands.tool.mcp_graph_lineage",
new_callable=AsyncMock,
return_value={
"root": {"id": "specs/search"},
"paths": [],
"generated_at": "2026-03-05T00:00:00Z",
},
)
def test_graph_lineage_json_output(mock_tool):
result = runner.invoke(cli_app, ["tool", "graph-lineage", "memory://specs/search"])
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert data["root"]["id"] == "specs/search"
assert mock_tool.call_args.kwargs["output_format"] == "json"
@patch(
"basic_memory.cli.commands.tool.mcp_graph_impact",
new_callable=AsyncMock,
return_value={
"target": {"id": "specs/search", "title": "specs/search"},
"affected": [],
"summary": {"total_considered": 0, "total_returned": 0},
},
)
def test_graph_impact_passthrough(mock_tool):
result = runner.invoke(
cli_app,
[
"tool",
"graph-impact",
"memory://specs/search",
"--horizon",
"3",
"--relation-filter",
"depends_on",
],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert mock_tool.call_args.kwargs["horizon"] == 3
assert mock_tool.call_args.kwargs["relation_filters"] == ["depends_on"]
assert mock_tool.call_args.kwargs["output_format"] == "json"
@patch(
"basic_memory.cli.commands.tool.mcp_graph_health",
new_callable=AsyncMock,
return_value={
"metrics": {
"orphan_rate": 0.0,
"stale_central_nodes": 0,
"overloaded_hubs": 0,
"contradiction_candidates": 0,
},
"issues": [],
"computed_at": "2026-03-05T00:00:00Z",
},
)
def test_graph_health_json_output(mock_tool):
result = runner.invoke(
cli_app,
["tool", "graph-health", "--scope", "specs", "--timeframe", "30d"],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert "metrics" in data
assert mock_tool.call_args.kwargs["scope"] == "specs"
assert mock_tool.call_args.kwargs["timeframe"] == "30d"
@patch(
"basic_memory.cli.commands.tool.mcp_fcm_simulate",
new_callable=AsyncMock,
return_value={
"baseline": [],
"projected": [],
"deltas": [],
"stability": {"converged": True, "iterations_used": 1, "residual": 0.0},
"confidence": 0.5,
},
)
def test_fcm_simulate_json_output(mock_tool):
result = runner.invoke(
cli_app,
[
"tool",
"fcm-simulate",
"--actions-json",
'[{"node_id":"n1","delta":0.2}]',
"--scenario-json",
'{"steps":8}',
],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert mock_tool.call_args.kwargs["actions"] == [{"node_id": "n1", "delta": 0.2}]
assert mock_tool.call_args.kwargs["scenario"] == {"steps": 8}
def test_fcm_simulate_invalid_actions_json():
result = runner.invoke(
cli_app,
["tool", "fcm-simulate", "--actions-json", '{"node_id":"n1","delta":0.2}'],
)
assert result.exit_code == 1
assert "expected a JSON array" in result.output
@patch(
"basic_memory.cli.commands.tool.mcp_fcm_rank_actions",
new_callable=AsyncMock,
return_value={"goal": {"node_id": "g1", "label": "g1"}, "recommendations": []},
)
def test_fcm_rank_actions_passthrough(mock_tool):
result = runner.invoke(
cli_app,
["tool", "fcm-rank-actions", "g1", "--constraints-json", '{"required_tags":["risk"]}'],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert mock_tool.call_args.kwargs["constraints"] == {"required_tags": ["risk"]}
assert mock_tool.call_args.kwargs["output_format"] == "json"
@patch(
"basic_memory.cli.commands.tool.mcp_fcm_import_model",
new_callable=AsyncMock,
return_value={
"import_id": "imp-1",
"nodes_loaded": 0,
"edges_loaded": 0,
"warnings": [],
"errors": [],
},
)
def test_fcm_import_model_json_output(mock_tool):
result = runner.invoke(
cli_app,
["tool", "fcm-import-model", "/tmp/model.csv", "--format", "csv_bundle_v1"],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert data["import_id"] == "imp-1"
assert mock_tool.call_args.kwargs["output_format"] == "json"
@patch(
"basic_memory.cli.commands.tool.mcp_fcm_export_model",
new_callable=AsyncMock,
return_value={
"export_id": "exp-1",
"format": "csv_bundle_v1",
"files": [],
"node_count": 0,
"edge_count": 0,
},
)
def test_fcm_export_model_json_output(mock_tool):
result = runner.invoke(
cli_app,
[
"tool",
"fcm-export-model",
"--format",
"csv_bundle_v1",
"--selection-json",
'{"scope":"all"}',
],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert data["export_id"] == "exp-1"
assert mock_tool.call_args.kwargs["selection"] == {"scope": "all"}
-216
View File
@@ -1,216 +0,0 @@
"""Tests for graph and FCM typed clients."""
from unittest.mock import MagicMock
import pytest
from basic_memory.mcp.clients import FCMClient, GraphClient
class TestGraphClient:
def test_init(self):
mock_http = MagicMock()
client = GraphClient(mock_http, "project-123")
assert client.http_client is mock_http
assert client.project_id == "project-123"
assert client._base_path == "/v2/projects/project-123/graph"
@pytest.mark.asyncio
async def test_lineage(self, monkeypatch):
from basic_memory.mcp.clients import graph as graph_mod
from basic_memory.schemas.graph_intelligence import GraphLineageRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"root": {"id": "specs/search", "title": "specs/search", "permalink": "specs/search"},
"paths": [],
"generated_at": "2026-03-05T00:00:00+00:00",
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/graph/lineage" in url
return mock_response
monkeypatch.setattr(graph_mod, "call_post", mock_call_post)
client = GraphClient(MagicMock(), "proj-123")
result = await client.lineage(GraphLineageRequest(start="memory://specs/search"))
assert result.root.id == "specs/search"
@pytest.mark.asyncio
async def test_impact(self, monkeypatch):
from basic_memory.mcp.clients import graph as graph_mod
from basic_memory.schemas.graph_intelligence import GraphImpactRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"target": {"id": "specs/search", "title": "specs/search"},
"affected": [],
"summary": {"total_considered": 0, "total_returned": 0},
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/graph/impact" in url
return mock_response
monkeypatch.setattr(graph_mod, "call_post", mock_call_post)
client = GraphClient(MagicMock(), "proj-123")
result = await client.impact(GraphImpactRequest(target="memory://specs/search", horizon=2))
assert result.summary.total_returned == 0
@pytest.mark.asyncio
async def test_health(self, monkeypatch):
from basic_memory.mcp.clients import graph as graph_mod
mock_response = MagicMock()
mock_response.json.return_value = {
"metrics": {
"orphan_rate": 0.0,
"stale_central_nodes": 0,
"overloaded_hubs": 0,
"contradiction_candidates": 0,
},
"issues": [],
"computed_at": "2026-03-05T00:00:00+00:00",
}
async def mock_call_get(client, url, **kwargs):
assert "/v2/projects/proj-123/graph/health" in url
assert kwargs["params"]["scope"] == "specs"
return mock_response
monkeypatch.setattr(graph_mod, "call_get", mock_call_get)
client = GraphClient(MagicMock(), "proj-123")
result = await client.health(scope="specs", timeframe="30d")
assert result.metrics.orphan_rate == 0.0
@pytest.mark.asyncio
async def test_reindex(self, monkeypatch):
from basic_memory.mcp.clients import graph as graph_mod
from basic_memory.schemas.graph_intelligence import GraphReindexRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"job_id": "job-123",
"status": "queued",
"scheduled_at": "2026-03-05T00:00:00+00:00",
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/graph/reindex" in url
return mock_response
monkeypatch.setattr(graph_mod, "call_post", mock_call_post)
client = GraphClient(MagicMock(), "proj-123")
result = await client.reindex(GraphReindexRequest(mode="full"))
assert result.status == "queued"
class TestFCMClient:
def test_init(self):
mock_http = MagicMock()
client = FCMClient(mock_http, "project-123")
assert client.http_client is mock_http
assert client.project_id == "project-123"
assert client._base_path == "/v2/projects/project-123/fcm"
@pytest.mark.asyncio
async def test_simulate(self, monkeypatch):
from basic_memory.mcp.clients import fcm as fcm_mod
from basic_memory.schemas.graph_intelligence import FCMSimulateRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"baseline": [{"node_id": "n1", "state": 0.0}],
"projected": [{"node_id": "n1", "state": 0.2}],
"deltas": [{"node_id": "n1", "delta": 0.2}],
"stability": {"converged": True, "iterations_used": 3, "residual": 0.0},
"confidence": 0.5,
"explanations": [],
"evidence_refs": [],
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/fcm/simulate" in url
return mock_response
monkeypatch.setattr(fcm_mod, "call_post", mock_call_post)
request = FCMSimulateRequest(actions=[{"node_id": "n1", "delta": 0.2}])
result = await FCMClient(MagicMock(), "proj-123").simulate(request)
assert result.stability.converged is True
@pytest.mark.asyncio
async def test_rank_actions(self, monkeypatch):
from basic_memory.mcp.clients import fcm as fcm_mod
from basic_memory.schemas.graph_intelligence import FCMRankActionsRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"goal": {"node_id": "g1", "label": "g1"},
"recommendations": [],
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/fcm/rank-actions" in url
return mock_response
monkeypatch.setattr(fcm_mod, "call_post", mock_call_post)
request = FCMRankActionsRequest(goal="g1")
result = await FCMClient(MagicMock(), "proj-123").rank_actions(request)
assert result.goal.node_id == "g1"
@pytest.mark.asyncio
async def test_import_model(self, monkeypatch):
from basic_memory.mcp.clients import fcm as fcm_mod
from basic_memory.schemas.graph_intelligence import FCMImportRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"import_id": "imp-1",
"nodes_loaded": 0,
"edges_loaded": 0,
"warnings": [],
"errors": [],
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/fcm/import" in url
return mock_response
monkeypatch.setattr(fcm_mod, "call_post", mock_call_post)
request = FCMImportRequest(source="/tmp/model.csv")
result = await FCMClient(MagicMock(), "proj-123").import_model(request)
assert result.import_id == "imp-1"
@pytest.mark.asyncio
async def test_export_model(self, monkeypatch):
from basic_memory.mcp.clients import fcm as fcm_mod
from basic_memory.schemas.graph_intelligence import FCMExportRequest
mock_response = MagicMock()
mock_response.json.return_value = {
"export_id": "exp-1",
"format": "csv_bundle_v1",
"files": [
{"name": "nodes.csv", "path": "/tmp/nodes.csv"},
{"name": "edges.csv", "path": "/tmp/edges.csv"},
],
"node_count": 0,
"edge_count": 0,
}
async def mock_call_post(client, url, **kwargs):
assert "/v2/projects/proj-123/fcm/export" in url
return mock_response
monkeypatch.setattr(fcm_mod, "call_post", mock_call_post)
request = FCMExportRequest()
result = await FCMClient(MagicMock(), "proj-123").export_model(request)
assert result.format == "csv_bundle_v1"
-2
View File
@@ -31,7 +31,6 @@ async def test_returns_none_when_no_default_and_no_project(config_manager, monke
config_manager.save_config(cfg)
monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False)
# Prevent API fallback from returning a project via stale dependency overrides
async def _no_api_fallback():
return None
@@ -118,7 +117,6 @@ async def test_returns_none_when_no_default(config_manager, monkeypatch):
config_manager.save_config(cfg)
monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False)
# Prevent API fallback from returning a project via stale dependency overrides
async def _no_api_fallback():
return None
-32
View File
@@ -35,31 +35,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
"expected_replacements",
"output_format",
],
"fcm_export_model": ["format", "selection", "project", "workspace", "output_format"],
"fcm_import_model": ["source", "format", "merge_mode", "project", "workspace", "output_format"],
"fcm_rank_actions": ["goal", "constraints", "top_k", "project", "workspace", "output_format"],
"fcm_simulate": ["actions", "scenario", "clamp_rules", "project", "workspace", "output_format"],
"fetch": ["id"],
"graph_health": ["scope", "timeframe", "project", "workspace", "output_format"],
"graph_impact": [
"target",
"horizon",
"relation_filters",
"include_reasons",
"project",
"workspace",
"output_format",
],
"graph_lineage": [
"start",
"goal",
"max_hops",
"relation_filters",
"project",
"workspace",
"output_format",
],
"graph_reindex": ["mode", "reason", "project", "workspace", "output_format"],
"list_directory": ["dir_name", "depth", "file_name_glob", "project", "workspace"],
"list_memory_projects": ["output_format", "workspace"],
"list_workspaces": ["output_format"],
@@ -137,15 +113,7 @@ TOOL_FUNCTIONS: dict[str, object] = {
"delete_note": tools.delete_note,
"delete_project": tools.delete_project,
"edit_note": tools.edit_note,
"fcm_export_model": tools.fcm_export_model,
"fcm_import_model": tools.fcm_import_model,
"fcm_rank_actions": tools.fcm_rank_actions,
"fcm_simulate": tools.fcm_simulate,
"fetch": tools.fetch,
"graph_health": tools.graph_health,
"graph_impact": tools.graph_impact,
"graph_lineage": tools.graph_lineage,
"graph_reindex": tools.graph_reindex,
"list_directory": tools.list_directory,
"list_memory_projects": tools.list_memory_projects,
"list_workspaces": tools.list_workspaces,
-114
View File
@@ -1,114 +0,0 @@
"""Tests for graph intelligence MCP tools."""
import pytest
from basic_memory.mcp.tools import (
fcm_export_model,
fcm_import_model,
fcm_rank_actions,
fcm_simulate,
graph_health,
graph_impact,
graph_lineage,
graph_reindex,
)
@pytest.mark.asyncio
async def test_graph_lineage_json_and_text_modes(app, test_project):
json_result = await graph_lineage(
start="memory://specs/search",
project=test_project.name,
output_format="json",
)
assert isinstance(json_result, dict)
assert set(["root", "paths", "generated_at"]).issubset(json_result.keys())
text_result = await graph_lineage(
start="memory://specs/search",
project=test_project.name,
output_format="text",
)
assert isinstance(text_result, str)
assert "Graph Lineage" in text_result
@pytest.mark.asyncio
async def test_graph_impact_and_health(app, test_project):
impact = await graph_impact(
target="memory://specs/search",
horizon=2,
project=test_project.name,
output_format="json",
)
assert isinstance(impact, dict)
assert set(["target", "affected", "summary"]).issubset(impact.keys())
health = await graph_health(
scope="specs",
timeframe="30d",
project=test_project.name,
output_format="json",
)
assert isinstance(health, dict)
assert set(["metrics", "issues", "computed_at"]).issubset(health.keys())
@pytest.mark.asyncio
async def test_graph_reindex(app, test_project):
result = await graph_reindex(project=test_project.name, output_format="json")
assert isinstance(result, dict)
assert result["status"] == "queued"
@pytest.mark.asyncio
async def test_fcm_simulate_and_rank_actions(app, test_project):
simulation = await fcm_simulate(
actions=[{"node_id": "n1", "delta": 0.2}],
project=test_project.name,
output_format="json",
)
assert isinstance(simulation, dict)
assert set(["baseline", "projected", "deltas", "stability", "confidence"]).issubset(
simulation.keys()
)
ranking = await fcm_rank_actions(
goal="reduce-regressions",
top_k=2,
project=test_project.name,
output_format="json",
)
assert isinstance(ranking, dict)
assert set(["goal", "recommendations"]).issubset(ranking.keys())
assert len(ranking["recommendations"]) <= 2
@pytest.mark.asyncio
async def test_fcm_import_export_json_and_text(app, test_project):
imported = await fcm_import_model(
source="/tmp/model.csv",
format="csv_bundle_v1",
project=test_project.name,
output_format="json",
)
assert isinstance(imported, dict)
assert "import_id" in imported
exported_json = await fcm_export_model(
format="csv_bundle_v1",
selection={"scope": "all"},
project=test_project.name,
output_format="json",
)
assert isinstance(exported_json, dict)
assert set(["export_id", "files", "node_count", "edge_count"]).issubset(exported_json.keys())
exported_text = await fcm_export_model(
format="csv_bundle_v1",
selection={"scope": "all"},
project=test_project.name,
output_format="text",
)
assert isinstance(exported_text, str)
assert "FCM Export" in exported_text