diff --git a/.github/workflows/spark-theatre-gate.yml b/.github/workflows/spark-theatre-gate.yml new file mode 100644 index 0000000..e6899fb --- /dev/null +++ b/.github/workflows/spark-theatre-gate.yml @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MPL-2.0 +# Estate SPARK Theatre Gate — thin caller of the reusable workflow in +# hyperpolymath/standards (#135 / #141). Pinned by commit SHA per the +# estate action-pinning policy. Regenerate the pin only when the reusable +# workflow is intentionally bumped. +name: SPARK Theatre Gate + +on: + pull_request: + push: + branches: [main, master] + +permissions: + contents: read + +jobs: + spark-theatre-gate: + uses: hyperpolymath/standards/.github/workflows/spark-theatre-gate.yml@462003782f3ebb93ea763e81d0d199ce13ef7d73 + with: + paths: "." + enforce_zero_contract: false diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index d0d7f4a..d0d516a 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -22,38 +22,38 @@ db = "verisim standalone (HTTP client; octad store); no federation unless needed name = "git-reticulator" purpose = "Semantic-lattice builder for git repositories: the symbolic half of a neuro-symbolic (Graph-RAG, proof-carrying retrieval) stack." primary-language = "rust" # host: CLI + REST shell -core-language = "affinescript" # intended lattice core (src/lattice/affine/*.affine) -completion-percentage = 15 +core-language = "affinescript" # intended core (future, ADR-006); Rust reference core today +completion-percentage = 35 [position] -phase = "design+skeleton" # design | skeleton | implementation | testing | maintenance +phase = "implementation" # design | skeleton | implementation | testing | maintenance maturity = "experimental" # experimental | alpha | beta | production | lts # ════════════════════════════════════════════════════════════════════════════ # HONEST STATUS — what is real vs. what is claimed # ════════════════════════════════════════════════════════════════════════════ [honest-status] -rust-host-loc = 237 # src/{lib.rs,cli/main.rs,api/app.rs} -rust-host-reality = "build_lattice/query_lattice are println! stubs; actix routes return canned JSON" -affine-core-loc = "~100 (models/storage/lattice.affine)" -affine-core-reality = "ASPIRATIONAL — AffineScript has no working compiler yet; these files cannot build, and as written they call Rust crates (git2/postgres) which AffineScript does not bind" -git-parsing = "NOT WIRED (git2 feature off by default; .affine calls git2 but cannot run)" +lattice-engine = "REAL (src/lattice/mod.rs): Kosaraju SCC-condensation + partial order + LOD zoom (sound+complete) + meet (LCA). cargo green; 10 property/unit tests." +ingest = "REAL but filesystem-only (src/ingest.rs, std-only, fail-soft). git2 HISTORY ingest NOT yet wired." +store = "REAL: LatticeStore trait + InMemoryStore default; VeriSimDB octad backend over HTTP behind --features verisim (src/store.rs). Not yet wired into CLI/REST." +host-reality = "CLI/REST still thin; the compat affine::{build_lattice,query_lattice} shim is now IO-free (no more println-only stubs)." embeddings = "NOT WIRED (tch/PyTorch feature off by default)" -persistence = "NOT WIRED (postgres feature off by default)" -proofs = "ZERO (no .idr/.v/.lean/.agda). The 'lattice' noun is currently unearned — see PROOF-NEEDS.md" -test-reality = "27 tests pass but they assert 'does not panic' over println stubs; honest as smoke tests, overstated as a 7-category pyramid" +affine-core = "DEFERRED (ADR-006 bridge-first): the Rust engine is the reference core; the .affine core lands after the Rust↔AffineScript bridge." +proofs = "ZERO mechanized (no .idr/.v/.ads). P2a/P4/P1b are TESTED not proved — see PROOF-NEEDS.md 'Proof status'. 'lattice' = meet-semilattice + digraph (full join not claimed)." +rust-spark = "Stance documented (docs/decisions/rust-spark-stance.adoc) + spark-theatre-gate.yml added (lenient). Idris2/Zig ABI seam N/A until an FFI surface exists." +tests = "30 pass (10 new lattice/ingest/store + 20 compat integration/property/api). cargo test exit 0." [crg] grade = "C" achieved = "2026-04-04" -caveat = "Grade C reflects test-CATEGORY presence, not behavioural coverage of real lattice logic (none exists yet). See READINESS.md + TEST-NEEDS.md." +caveat = "Superseded 2026-06-04: the lattice engine is real and its properties are tested (P2a/P4/P1b). Mechanized proofs still owed (PROOF-NEEDS.md). See READINESS.md + TEST-NEEDS.md." next = "Grade B requires 6 quality targets (lint/fmt/doc-coverage/etc.)" [blockers-and-issues] blockers = [ - "affinescript-runtime path dep unresolvable without sibling repo (severity: low; feature-gated off)", - "the .affine core cannot compile (AffineScript compiler not ready) AND is written against Rust crates it cannot bind (severity: high; design rethink needed)", - "'lattice' is currently a typed DAG with no proven meet/join (severity: medium; see PROOF-NEEDS.md)", + "RESOLVED 2026-06-04: removed the unresolvable affinescript-runtime path dep from Cargo.toml (cargo build now green); re-add as git/published dep when the bridge lands (ADR-006)", + "Deliverable 1 (Rust↔AffineScript bridge, in affinescript repo) blocks the .affine core migration (severity: high; docs/MIGRATION-PLAN.adoc)", + "'lattice' is a meet-semilattice + digraph; meet/zoom tested not proved (severity: medium; PROOF-NEEDS.md)", ] template-debt = [ "RESOLVED 2026-06-03: 0-AI-MANIFEST.a2ml had [YOUR-REPO-NAME]/{{AUTHOR}} placeholders", @@ -70,7 +70,7 @@ template-debt = [ # Core-language question DECIDED 2026-06-04 (ADR-006): AffineScript-first, bridge-first. actions = [ "DELIVERABLE 1 (in affinescript repo, runtime/): build the Rust loader + marshalling for compiled AffineScript — the affine-js equivalent for Rust. Acceptance: a Rust test loads a compiled .affine fn, calls it, round-trips a string via a host extern fn. See docs/MIGRATION-PLAN.adoc.", - "DELIVERABLE 2 (here, once 1 lands): thin Rust host (git2 + verisim HTTP DB + actix) + pure .affine lattice core over Wasm, behind a `trait LatticeCore` seam.", + "LANDED 2026-06-04: Rust reference lattice core (SCC-condensation + partial order + LOD zoom + meet) + filesystem ingest + verisim store seam + 10 tests; cargo green; Rust/SPARK stance + spark-theatre-gate added. NEXT here: wire CLI/REST to ingest→lattice→store, add criterion benches, git2 history ingest.", "Earn the 'lattice' word: SCC-condensation + partial order in the core, then discharge PROOF-NEEDS.md P1-P2 (Idris2).", "Tests/benches: replace smoke-over-stubs with property tests (partial-order laws, zoom soundness/completeness) + criterion benches; strict-but-passable CI with an AffineScript compile gate.", ] diff --git a/Cargo.lock b/Cargo.lock index b0f1dd1..831f904 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,10 +202,6 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "affinescript-runtime" -version = "0.1.0" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -1043,7 +1039,6 @@ name = "git-reticulator" version = "0.1.0" dependencies = [ "actix-web", - "affinescript-runtime", "clap 4.6.0", "criterion", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index 853e8f5..09c5fd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,9 @@ env_logger = "0.10" clap = { version = "4.0", features = ["derive"] } # Optional dependencies (feature-gated) -affinescript = { package = "affinescript-runtime", path = "../nextgen-languages/affinescript/runtime", optional = true } +# NOTE: the affinescript-runtime dependency is intentionally absent. AffineScript +# integration is deferred until the Rust↔AffineScript bridge exists (ADR-006); +# it will be re-added as a git/published dep (not a path dep) when that lands. reqwest = { version = "0.12", features = ["json"], optional = true } git2 = { version = "0.20", optional = true } # Git repo parsing postgres = { version = "0.19", features = ["with-uuid-1"], optional = true } @@ -27,12 +29,12 @@ tch = { version = "0.13", optional = true } # PyTorch embeddings [features] default = [] -affinescript-engine = ["affinescript"] # AffineScript runtime integration +verisim = ["reqwest"] # VeriSimDB octad store over HTTP (src/store.rs) git-integration = ["git2"] # Git repository parsing -db = ["postgres"] # PostgreSQL connectivity +db = ["postgres"] # PostgreSQL connectivity (legacy; superseded by verisim) embeddings = ["tch"] # PyTorch-based embeddings http-client = ["reqwest"] # HTTP client for external calls -full = ["affinescript-engine", "git-integration", "db", "embeddings", "http-client"] +full = ["verisim", "git-integration", "db", "embeddings", "http-client"] [dev-dependencies] criterion = { version = "0.4", features = ["html_reports"] } # Benchmarks diff --git a/PROOF-NEEDS.md b/PROOF-NEEDS.md index db0c6ad..e6e3d2e 100644 --- a/PROOF-NEEDS.md +++ b/PROOF-NEEDS.md @@ -29,6 +29,60 @@ estate friction. The order-theory here is elementary; the value is in *connecting the proofs to the running code*, not in their depth. +## Proof status (by category) — 2026-06-04 + +Honest categorisation. **Proved** = mechanically checked (Idris2/Coq/SPARK). +**Tested** = executable checks + unit tests in the Rust reference core +(`src/lattice/mod.rs`) — a rung *below* proof. git-reticulator has **zero +mechanized proofs**; what exists is tested. + +### Done (tested, not proved) +- **P2a** SCC condensation is acyclic — `Condensation::is_acyclic` (Kahn + topological sort, a genuine runtime check, not a trust assertion) + tests + (the 3-node call cycle collapses to one component; condensation is a DAG). +- **P4** LOD `zoom` soundness + completeness — tested on fixtures (defs in + other files excluded; every descendant returned). +- **P1b (fragment)** `meet` = lowest common ancestor — tested (idempotent, + commutative, LCA correct). +- **P1a (fragment)** reflexivity of ≤ — tested. + +### Not attempted +- **P1** full lattice laws (associativity/absorption; **join**). +- **P3** monotone abstraction (commit-DAG → lattice) — also: git-history ingest + isn't wired (`src/ingest.rs` is filesystem-only). +- **P5** determinism/confluence. **P6** drift-predicate totality. +- **P7** pgRouting≡lattice — now **N/A** (verisim is the store, not pgRouting). +- In the *mechanized-proof* sense, **all of P1–P7 are unattempted** (zero `.idr`). + +### Sorries / `believe_me` / proof escapes +- **Zero — but vacuously.** There are no proofs, so there are no escape hatches. + This is **not** vcl-ut's "zero `believe_me` in a real corpus" achievement; it + is zero-because-empty. Recorded so it is never mistaken for rigour we lack. + +### Structural blockers +- No prover wired (Idris2 recommended, absent; no proof-corpus CI gate). +- The verifiable core is migrating to AffineScript (ADR-006), itself alpha with + the CORE-01 soundness gap. +- The Rust/SPARK Idris2/Zig ABI seam is **N/A until git-reticulator exposes an + FFI surface** (it's a CLI/REST app today) — see + `docs/decisions/rust-spark-stance.adoc`. + +### False (claims that would be untrue — and are correctly avoided) +- "git-reticulator builds a **lattice**" (full lattice) is **false**: arbitrary + sibling nodes have no unique join. The code is honest — it implements `meet` + only and names the structure a **meet-semilattice** + typed digraph. The true, + scoped claim is evidenced by test; the false strong claim is not made. + +### What it means / how much to worry +- **Low worry** for an early, non-safety-critical research tool. The property + that matters — and holds — is that **nothing is overclaimed**: code and docs + say "tested not proved", "meet-semilattice not lattice", "zero proofs". That + honesty *is* the estate bar (the doc-truthing / SPARK-theatre culture is about + not faking verification). The residual risk is correctness-confidence (a + zoom/meet edge case could ship), not safety. The path up is cheap and known: + P2→P1→P4 in Idris2, or largely for free from the AffineScript core's type + discipline post-migration. + ## What Needs Proving ### P1 — It is actually a lattice (or: rename it) — **HIGH** diff --git a/TEST-NEEDS.md b/TEST-NEEDS.md index 6c2f01a..0c114d4 100644 --- a/TEST-NEEDS.md +++ b/TEST-NEEDS.md @@ -2,30 +2,31 @@ ## CRG Grade: C — ACHIEVED 2026-04-04 -All required test categories for CRG Grade C are present and passing. +All required test categories present and passing; `cargo test` green. -> **Honest caveat (2026-06-03):** these 27 tests are *smoke-level* — they call -> `build_lattice`/`query_lattice` (which are `println!` stubs) and assert "does -> not panic". They verify the host compiles and the API surface is reachable; -> they do **not** exercise real lattice logic (there is none yet). The -> property/contract/aspect/e2e labels describe test *categories*, not -> behavioural depth. Behavioural + property tests over a real lattice are owed -> alongside `PROOF-NEEDS.md` P1–P4. +> **Update 2026-06-04:** the Rust lattice engine is now real, so the suite is no +> longer smoke-over-stubs. The 10 new lib tests assert genuine lattice +> **properties** (SCC-condensation acyclicity, zoom soundness+completeness, +> meet=LCA, reflexivity) — *tested, not proved* (see PROOF-NEEDS.md "Proof +> status"). The 20 pre-existing integration/property/api tests still pass via the +> IO-free compat shim. ### Test Inventory | Category | Status | Location | Count | |---|---|---|---| -| Unit | PASS | `src/lib.rs` (`#[cfg(test)]` `unit_tests` module) | 7 | -| Smoke | PASS | `src/lib.rs` (`unit_tests` module) | 2 | -| Property-based (P2P) | PASS | `tests/property_tests.rs` | 5 | +| Lattice properties | PASS | `src/lattice/mod.rs` (`#[cfg(test)]`) | 6 | +| Ingest | PASS | `src/ingest.rs` (`#[cfg(test)]`) | 3 | +| Store | PASS | `src/store.rs` (`#[cfg(test)]`) | 1 | +| Property-based | PASS | `tests/property_tests.rs` | 5 | | E2E / Reflexive | PASS | `tests/integration_tests.rs` | 4 | | Contract | PASS | `tests/integration_tests.rs` | 3 | | Aspect | PASS | `tests/integration_tests.rs` | 6 | +| API / CLI | PASS | `tests/api_tests.rs` | 2 | | Benchmarks (baselined) | PASS | `benches/git_reticulator_bench.rs` | 6 | -Total tests: **27** -Benchmarks: **6** (Criterion, compile-verified with `cargo bench --no-run`) +Total tests: **30** (`cargo test` exit 0) +Benchmarks: **6** (Criterion; behavioural criterion benches over fixture repos are owed for the new engine) ### Commands diff --git a/docs/decisions/rust-spark-stance.adoc b/docs/decisions/rust-spark-stance.adoc new file mode 100644 index 0000000..de6b383 --- /dev/null +++ b/docs/decisions/rust-spark-stance.adoc @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += Rust/SPARK Stance — git-reticulator +:toc: macro +:toclevels: 2 + +toc::[] + +== Summary + +git-reticulator's application logic is written in Rust. Per the estate +language policy (`standards/.../LANGUAGE-POLICY.adoc` §Terminology), +*"Rust" means "Rust/SPARK"*: Rust is the primary implementation language +now, but a repo must be *designed to admit SPARK/Ada verification modules* +across a stable, Idris2-typed boundary, with Zig as the FFI layer — and +**Rust is never the ABI/API/FFI layer**. + +This document records the stance honestly. git-reticulator is at an +*earlier* stage than the exemplar (verisimdb): it is currently a CLI + REST +**application** plus a pure, dependency-free lattice **engine**, and it +does **not yet expose a C ABI**. The Idris2-typed seam + Zig FFI therefore +do not exist yet — not as a hidden gap, but because there is no FFI surface +to put them on. This document says so, and says where the seam *will* go. + +== What is correctness-critical here + +git-reticulator is a retrieval-geometry engine, not a ledger. The +correctness-critical surface is the lattice algebra. + +[cols="1,3",options="header"] +|=== +| Critical surface | Where | Status + +| SCC condensation is acyclic (a real partial order exists) +| `src/lattice/mod.rs` — `Lattice::condense`, `Condensation::is_acyclic` +| implemented + unit-tested; *not* mechanically proved (PROOF-NEEDS P2) + +| LOD `zoom` soundness + completeness (no spurious/dropped context) +| `src/lattice/mod.rs` — `Lattice::zoom` +| implemented + unit-tested; *not* proved (PROOF-NEEDS P4) + +| `meet` = lowest common ancestor (meet-semilattice fragment) +| `src/lattice/mod.rs` — `Lattice::meet` +| implemented + unit-tested; *not* proved (PROOF-NEEDS P1b) +|=== + +NOTE: full lattice `join` is **not** claimed — see PROOF-NEEDS.md. The +structure is a meet-semilattice over the containment forest plus a typed +digraph whose SCC-condensation is a partial order. + +== The seam (not yet present — and where it will go) + +There is no Idris2-typed ABI seam or Zig FFI layer today (no boundary `*.idr` +modules, no FFI crate) because git-reticulator exposes no C ABI. The seam will appear at the **AffineScript→Wasm boundary** +once the migration (ADR-006) lands: the pure lattice core moves to +AffineScript compiled to Wasm, the Rust host provides IO via `extern fn` +host imports, and *that* typed boundary (AffineScript's own type discipline, +with an Idris2/SPARK admission path on the host side) is where verification +attaches. Until then, the honest status is **N/A — no FFI surface**. + +== SPARK/Ada admission path + +Deferred. No SPARK/Ada code exists. When a correctness-critical component +(e.g. the condensation/zoom kernel) warrants it, it can be re-implemented in +SPARK/Ada behind the future Wasm/C-ABI contract without redesigning the host +— which is what §Terminology requires of a "designed to admit SPARK/Ada" +Rust project. + +== Honest gaps + +* **Zero mechanized proofs.** No `.idr`/`.ads`/`.v` proof files. The + lattice obligations are *tested, not proved*; Idris2 is recommended + (PROOF-NEEDS.md) but not wired into CI. "Zero proof escapes" here is + vacuous (there are no proofs to escape from) — it is **not** the + vcl-ut-grade "zero `believe_me` in a real corpus" achievement. +* The verifiable core is **migrating to AffineScript** (ADR-006), which is + itself alpha with a known soundness gap (CORE-01). +* The `spark-theatre-gate` runs in **lenient mode** + (`enforce_zero_contract: false`) — appropriate for this stage; tighten + when a real ABI seam + proofs exist. + +== References + +* Estate exemplar: `verisimdb/docs/decisions/rust-spark-stance.adoc` +* This repo's obligations: `PROOF-NEEDS.md` +* Migration that introduces the seam: `docs/MIGRATION-PLAN.adoc`, META ADR-006 diff --git a/src/api/app.rs b/src/api/app.rs index 1b810ca..fa72b57 100644 --- a/src/api/app.rs +++ b/src/api/app.rs @@ -22,12 +22,16 @@ pub struct ApiResponse { #[post("/build")] async fn build_lattice(req: web::Json) -> impl Responder { - println!("🚀 API: Reticulating repo: {}", req.repo); - affine::build_lattice(&req.repo, &req.db); - HttpResponse::Ok().json(ApiResponse { - status: "success".to_string(), - message: format!("Lattice built for {}", req.repo), - }) + let lattice = crate::ingest::from_path(&req.repo); + let cond = lattice.condense(); + HttpResponse::Ok().json(serde_json::json!({ + "status": "success", + "repo": req.repo, + "nodes": lattice.len(), + "edges": lattice.edges().len(), + "components": cond.num_components, + "acyclic": cond.is_acyclic(), + })) } #[get("/zoom/{node_id}")] diff --git a/src/cli/main.rs b/src/cli/main.rs index a8c1cdb..7804bc9 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -1,5 +1,6 @@ use clap::{Parser, Subcommand}; use git_reticulator::lattice::affine; +use git_reticulator::store::LatticeStore; #[derive(Parser)] #[command(name = "reticulate")] @@ -44,9 +45,41 @@ async fn main() { match &cli.command { Commands::Build { repo, db } => { - println!("🚀 Starting reticulation process..."); - affine::build_lattice(repo, db); - println!("✅ Semantic lattice built and stored."); + println!("🚀 Reticulating {repo} ..."); + let lattice = git_reticulator::ingest::from_path(repo); + let cond = lattice.condense(); + println!( + " {} nodes · {} edges · {} components · acyclic={}", + lattice.len(), + lattice.edges().len(), + cond.num_components, + cond.is_acyclic() + ); + + #[cfg(feature = "verisim")] + let to_verisim = if db.starts_with("http://") || db.starts_with("https://") { + let store = git_reticulator::store::verisim::VerisimStore::new(db.clone()); + match store.persist(&lattice).await { + Ok(n) => println!("📦 persisted {n} octads to VeriSimDB ({db})"), + Err(e) => eprintln!("⚠️ verisim persist failed: {e}"), + } + true + } else { + false + }; + #[cfg(not(feature = "verisim"))] + let to_verisim = false; + + if !to_verisim { + let mut store = git_reticulator::store::InMemoryStore::new(); + let n = match store.persist(&lattice) { + Ok(n) => n, + // InMemoryStore is Infallible — this arm is unreachable. + Err(never) => match never {}, + }; + println!("📦 persisted {n} nodes to the in-memory store (target: {db})"); + } + println!("✅ done."); } Commands::Query { zoom, db } => { println!("🔍 Querying lattice for context: {}", zoom); diff --git a/src/ingest.rs b/src/ingest.rs new file mode 100644 index 0000000..00cffc1 --- /dev/null +++ b/src/ingest.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// src/ingest.rs +// +// Repository ingestion. The default path is dependency-free: a std-only +// filesystem walk that turns a directory tree into a Module/File/Definition +// lattice. (Git-history ingestion via `git2` is a future feature seam.) +// +// Every operation is fail-soft: unreadable paths or files yield a smaller +// lattice, never a panic — the resilience tests feed this arbitrary input. + +use crate::lattice::{Lattice, LatticeBuilder, NodeId, SemanticLevel}; +use std::fs; +use std::path::Path; + +/// Maximum directory depth to descend (guards against symlink loops / runaway trees). +const MAX_DEPTH: usize = 64; +/// Maximum number of definition keywords extracted per file. +const MAX_DEFS_PER_FILE: usize = 256; + +/// Build a lattice from a filesystem directory tree rooted at `root`. +/// +/// Never panics. A non-existent or unreadable `root` yields a one-node lattice +/// (the module root) with no children. +pub fn from_path(root: &str) -> Lattice { + let mut builder = LatticeBuilder::new(); + let root_path = Path::new(root); + let root_name = root_path + .file_name() + .and_then(|s| s.to_str()) + .filter(|s| !s.is_empty()) + .unwrap_or(root) + .to_string(); + let module_id = builder.add_keyword(root_name, root.to_string(), SemanticLevel::Module, None); + walk(&mut builder, root_path, module_id, 0); + builder.build() +} + +fn walk(builder: &mut LatticeBuilder, dir: &Path, parent: NodeId, depth: usize) { + if depth >= MAX_DEPTH { + return; + } + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, // fail-soft: unreadable directory + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + // Skip hidden entries (.git, .github, …) — they are not source units. + if name.starts_with('.') { + continue; + } + if path.is_dir() { + let sub = builder.add_keyword( + name, + path.to_string_lossy().to_string(), + SemanticLevel::Module, + Some(parent), + ); + walk(builder, &path, sub, depth + 1); + } else if path.is_file() { + let file_id = builder.add_keyword( + name, + path.to_string_lossy().to_string(), + SemanticLevel::File, + Some(parent), + ); + if let Ok(content) = fs::read_to_string(&path) { + let file_disp = path.to_string_lossy().to_string(); + for def in extract_definitions(&content) { + builder.add_keyword(def, file_disp.clone(), SemanticLevel::Definition, Some(file_id)); + } + } + } + } +} + +/// Crude but real, language-agnostic definition extraction: capture the +/// identifier following a common definition keyword at the start of a line. +fn extract_definitions(content: &str) -> Vec { + const KEYWORDS: [&str; 11] = [ + "pub fn ", "fn ", "def ", "class ", "struct ", "enum ", "trait ", "type ", "module ", + "interface ", "func ", + ]; + let mut defs = Vec::new(); + for line in content.lines() { + let trimmed = line.trim_start(); + for kw in KEYWORDS { + if let Some(rest) = trimmed.strip_prefix(kw) { + let ident: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !ident.is_empty() { + defs.push(ident); + } + break; + } + } + if defs.len() >= MAX_DEFS_PER_FILE { + break; + } + } + defs.sort(); + defs.dedup(); + defs +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nonexistent_path_yields_root_only() { + let lattice = from_path("/no/such/path/should/exist/12345"); + assert_eq!(lattice.len(), 1); // just the module root + } + + #[test] + fn empty_path_does_not_panic() { + let _ = from_path(""); + } + + #[test] + fn extracts_definitions_from_source_text() { + let defs = extract_definitions("pub fn login() {}\nstruct Session;\n// comment\nfn helper() {}"); + assert!(defs.contains(&"login".to_string())); + assert!(defs.contains(&"Session".to_string())); + assert!(defs.contains(&"helper".to_string())); + } +} diff --git a/src/lattice/mod.rs b/src/lattice/mod.rs new file mode 100644 index 0000000..86d25f9 --- /dev/null +++ b/src/lattice/mod.rs @@ -0,0 +1,497 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// src/lattice/mod.rs +// +// The git-reticulator lattice engine. This is the pure, dependency-free, +// algorithmic core: it earns the word "lattice" (PROOF-NEEDS.md P1/P2) by +// SCC-condensing the typed relationship digraph into a DAG, deriving a partial +// order from it, and implementing the LOD `zoom` (P4) and containment `meet`. +// +// ARCHITECTURE: this Rust implementation is the reference core today. It sits +// behind the `LatticeCore`-shaped API so a future AffineScript→Wasm core can +// replace it without touching the host (ADR-001, AffineScript-first target). +// All IO (git ingestion, verisim persistence) lives outside this module. + +use std::collections::{BTreeSet, HashMap, VecDeque}; + +/// Index of a node into [`Lattice::nodes`]. Stable for the lattice's lifetime. +pub type NodeId = usize; + +/// Level-of-detail tier. The containment hierarchy (`parent`) runs +/// Module ⊃ File ⊃ Definition ⊃ Block. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SemanticLevel { + Module, + File, + Definition, + Block, +} + +impl SemanticLevel { + pub fn as_str(self) -> &'static str { + match self { + SemanticLevel::Module => "module", + SemanticLevel::File => "file", + SemanticLevel::Definition => "definition", + SemanticLevel::Block => "block", + } + } + + /// Coarse-to-fine rank (Module = 0 … Block = 3). + pub fn rank(self) -> u8 { + match self { + SemanticLevel::Module => 0, + SemanticLevel::File => 1, + SemanticLevel::Definition => 2, + SemanticLevel::Block => 3, + } + } +} + +/// A semantic keyword node: simultaneously a lattice element (order position via +/// `parent`/edges) and a neural element (`embedding`) — the neuro-symbolic seam. +#[derive(Clone, Debug)] +pub struct Keyword { + pub id: NodeId, + pub name: String, + pub file: String, + pub level: SemanticLevel, + pub parent: Option, + pub embedding: Option>, + pub cluster: Option, +} + +/// A typed, weighted relationship (calls / contains / depends_on / …). +#[derive(Clone, Debug)] +pub struct Relationship { + pub source: NodeId, + pub target: NodeId, + pub weight: f64, + pub rel_type: String, +} + +/// The SCC condensation of a lattice's relationship digraph: the acyclic +/// quotient on which a genuine partial order exists (PROOF-NEEDS P2). +#[derive(Clone, Debug)] +pub struct Condensation { + /// Component id for each node (parallel to [`Lattice::nodes`]). + pub component_of: Vec, + /// Number of strongly-connected components. + pub num_components: usize, + /// DAG adjacency between components (deduplicated, self-loops removed). + pub dag_adj: Vec>, +} + +impl Condensation { + /// Verify acyclicity by Kahn topological sort (PROOF-NEEDS P2a). A genuine + /// check, not a trust-the-construction assertion: returns false iff a cycle + /// survived condensation (which must never happen). + pub fn is_acyclic(&self) -> bool { + let n = self.num_components; + let mut indeg = vec![0usize; n]; + for adj in &self.dag_adj { + for &v in adj { + indeg[v] += 1; + } + } + let mut queue: VecDeque = (0..n).filter(|&i| indeg[i] == 0).collect(); + let mut processed = 0; + while let Some(u) = queue.pop_front() { + processed += 1; + for &v in &self.dag_adj[u] { + indeg[v] -= 1; + if indeg[v] == 0 { + queue.push_back(v); + } + } + } + processed == n + } + + /// Set of components reachable from `from` (inclusive). + pub fn reaches(&self, from: usize) -> BTreeSet { + let mut seen = BTreeSet::new(); + let mut stack = vec![from]; + seen.insert(from); + while let Some(u) = stack.pop() { + for &v in &self.dag_adj[u] { + if seen.insert(v) { + stack.push(v); + } + } + } + seen + } + + /// Partial-order relation on components: `a ≤ b` iff `a` reaches `b`. + /// Reflexive (a reaches a), antisymmetric (acyclic), transitive. + pub fn precedes(&self, a: usize, b: usize) -> bool { + self.reaches(a).contains(&b) + } +} + +/// A semantic lattice: typed keyword nodes + weighted relationships, with the +/// algebra that earns the name (condensation, partial order, meet, LOD zoom). +#[derive(Clone, Debug, Default)] +pub struct Lattice { + nodes: Vec, + edges: Vec, +} + +impl Lattice { + pub fn nodes(&self) -> &[Keyword] { + &self.nodes + } + pub fn edges(&self) -> &[Relationship] { + &self.edges + } + pub fn len(&self) -> usize { + self.nodes.len() + } + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + pub fn node(&self, id: NodeId) -> Option<&Keyword> { + self.nodes.get(id) + } + + fn adjacency(&self) -> (Vec>, Vec>) { + let n = self.nodes.len(); + let mut adj = vec![Vec::new(); n]; + let mut radj = vec![Vec::new(); n]; + for e in &self.edges { + if e.source < n && e.target < n { + adj[e.source].push(e.target); + radj[e.target].push(e.source); + } + } + (adj, radj) + } + + fn children_map(&self) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for kw in &self.nodes { + if let Some(parent) = kw.parent { + map.entry(parent).or_default().push(kw.id); + } + } + map + } + + /// SCC condensation via Kosaraju (iterative — no recursion, so deep/large + /// graphs cannot overflow the stack). PROOF-NEEDS P2. + pub fn condense(&self) -> Condensation { + let n = self.nodes.len(); + let (adj, radj) = self.adjacency(); + + // Pass 1: order nodes by DFS finish time (iterative post-order). + let mut visited = vec![false; n]; + let mut order = Vec::with_capacity(n); + for start in 0..n { + if visited[start] { + continue; + } + visited[start] = true; + let mut stack: Vec<(usize, usize)> = vec![(start, 0)]; + while let Some(&(u, i)) = stack.last() { + if i < adj[u].len() { + // Advance the cursor on the current frame without unwrap(): + // the frame is guaranteed present (stack.last() just + // matched), but we avoid an unguarded panic site (CWE-754). + if let Some(top) = stack.last_mut() { + top.1 += 1; + } + let v = adj[u][i]; + if !visited[v] { + visited[v] = true; + stack.push((v, 0)); + } + } else { + order.push(u); + stack.pop(); + } + } + } + + // Pass 2: assign components on the transpose, in reverse finish order. + let mut component_of = vec![usize::MAX; n]; + let mut count = 0; + for &start in order.iter().rev() { + if component_of[start] != usize::MAX { + continue; + } + component_of[start] = count; + let mut stack = vec![start]; + while let Some(u) = stack.pop() { + for &v in &radj[u] { + if component_of[v] == usize::MAX { + component_of[v] = count; + stack.push(v); + } + } + } + count += 1; + } + + // Build the DAG adjacency between components. + let mut dag_adj = vec![BTreeSet::new(); count]; + for e in &self.edges { + if e.source < n && e.target < n { + let (cu, cv) = (component_of[e.source], component_of[e.target]); + if cu != cv { + dag_adj[cu].insert(cv); + } + } + } + + Condensation { + component_of, + num_components: count, + dag_adj, + } + } + + /// Node-level partial order derived from the condensation: `a ≤ b`. + /// PROOF-NEEDS P1a/P3. + pub fn precedes(&self, a: NodeId, b: NodeId) -> bool { + if a >= self.nodes.len() || b >= self.nodes.len() { + return false; + } + let cond = self.condense(); + cond.precedes(cond.component_of[a], cond.component_of[b]) + } + + /// LOD zoom: the *exact* set of transitive descendants of `node` (via the + /// containment hierarchy) whose level equals `level`. Sound (only genuine + /// descendants) and complete (every such descendant). PROOF-NEEDS P4. + pub fn zoom(&self, node: NodeId, level: SemanticLevel) -> Vec { + let children = self.children_map(); + let mut result = Vec::new(); + let mut seen = BTreeSet::new(); + let mut stack: Vec = children.get(&node).cloned().unwrap_or_default(); + while let Some(u) = stack.pop() { + if !seen.insert(u) { + continue; + } + if self.nodes[u].level == level { + result.push(u); + } + if let Some(kids) = children.get(&u) { + stack.extend(kids.iter().copied()); + } + } + result.sort_unstable(); + result + } + + fn ancestor_chain(&self, a: NodeId) -> Vec { + let mut chain = Vec::new(); + let mut cur = Some(a); + let mut guard = 0; + while let Some(x) = cur { + if guard > self.nodes.len() { + break; // defensive: malformed parent cycle + } + chain.push(x); + cur = self.nodes.get(x).and_then(|k| k.parent); + guard += 1; + } + chain + } + + /// Meet (greatest lower bound) in the containment forest: the lowest common + /// ancestor of `a` and `b`. The hierarchy is a meet-semilattice; this is the + /// honest, provable fragment of PROOF-NEEDS P1b (full lattice join is NOT + /// claimed — see PROOF-NEEDS.md). + pub fn meet(&self, a: NodeId, b: NodeId) -> Option { + if a >= self.nodes.len() || b >= self.nodes.len() { + return None; + } + let chain_a = self.ancestor_chain(a); + let ancestors_b: BTreeSet = self.ancestor_chain(b).into_iter().collect(); + chain_a.into_iter().find(|x| ancestors_b.contains(x)) + } +} + +/// Builder for [`Lattice`]. Node ids are assigned sequentially and equal the +/// insertion index, so relationships can reference them directly. +#[derive(Debug, Default)] +pub struct LatticeBuilder { + nodes: Vec, + edges: Vec, +} + +impl LatticeBuilder { + pub fn new() -> Self { + Self::default() + } + + /// Add a keyword node, returning its id. + pub fn add_keyword( + &mut self, + name: String, + file: String, + level: SemanticLevel, + parent: Option, + ) -> NodeId { + let id = self.nodes.len(); + self.nodes.push(Keyword { + id, + name, + file, + level, + parent, + embedding: None, + cluster: None, + }); + id + } + + /// Attach an embedding vector to a previously-added node (neuro-symbolic seam). + pub fn set_embedding(&mut self, id: NodeId, embedding: Vec) { + if let Some(node) = self.nodes.get_mut(id) { + node.embedding = Some(embedding); + } + } + + /// Add a typed weighted relationship. Endpoints outside the current node set + /// are silently ignored at condensation time, so this never panics. + pub fn add_relationship( + &mut self, + source: NodeId, + target: NodeId, + weight: f64, + rel_type: String, + ) { + self.edges.push(Relationship { + source, + target, + weight, + rel_type, + }); + } + + pub fn build(self) -> Lattice { + Lattice { + nodes: self.nodes, + edges: self.edges, + } + } +} + +// --------------------------------------------------------------------------- +// Backwards-compatible thin shim (the historical `lattice::affine` surface). +// Deliberately IO-free and fast so the legacy resilience tests (arbitrary / +// huge / injection inputs) stay panic-free and quick. Real ingestion lives in +// `crate::ingest`; real persistence in `crate::store`. +// --------------------------------------------------------------------------- +pub mod affine { + use super::{LatticeBuilder, SemanticLevel}; + + /// Compat entry point. Builds a trivial single-node lattice from `repo` and + /// reports an engine summary. Never touches the filesystem or network. + pub fn build_lattice(repo: &str, db: &str) { + let mut builder = LatticeBuilder::new(); + builder.add_keyword(repo.to_string(), repo.to_string(), SemanticLevel::Module, None); + let lattice = builder.build(); + let cond = lattice.condense(); + println!( + "reticulated {repo}: {} node(s), {} component(s), acyclic={} [target: {db}]", + lattice.len(), + cond.num_components, + cond.is_acyclic() + ); + } + + /// Compat entry point for a zoom request. IO-free. + pub fn query_lattice(zoom: &str, db: &str) { + println!("zoom target '{zoom}' [source: {db}] — run `reticulate build` to populate a lattice"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a small fixture: one Module containing two Files, each with a + /// couple of Definitions, plus a cyclic call relationship. + fn fixture() -> Lattice { + let mut b = LatticeBuilder::new(); + let m = b.add_keyword("root".into(), "/".into(), SemanticLevel::Module, None); + let f1 = b.add_keyword("auth.rs".into(), "/auth.rs".into(), SemanticLevel::File, Some(m)); + let f2 = b.add_keyword("db.rs".into(), "/db.rs".into(), SemanticLevel::File, Some(m)); + let d1 = b.add_keyword("login".into(), "/auth.rs".into(), SemanticLevel::Definition, Some(f1)); + let d2 = b.add_keyword("session".into(), "/auth.rs".into(), SemanticLevel::Definition, Some(f1)); + let d3 = b.add_keyword("connect".into(), "/db.rs".into(), SemanticLevel::Definition, Some(f2)); + // login -> session -> connect -> login (a cycle, to exercise SCC) + b.add_relationship(d1, d2, 1.0, "calls".into()); + b.add_relationship(d2, d3, 1.0, "calls".into()); + b.add_relationship(d3, d1, 1.0, "calls".into()); + b.build() + } + + #[test] + fn condensation_is_acyclic_even_with_cycles() { + let lat = fixture(); + let cond = lat.condense(); + assert!(cond.is_acyclic(), "condensation must always be a DAG"); + // the 3-node call cycle collapses to a single component + assert!(cond.num_components < lat.len()); + } + + #[test] + fn cycle_nodes_share_a_component() { + let lat = fixture(); + let cond = lat.condense(); + // login(3), session(4), connect(5) form the cycle + assert_eq!(cond.component_of[3], cond.component_of[4]); + assert_eq!(cond.component_of[4], cond.component_of[5]); + } + + #[test] + fn precedes_is_reflexive() { + let lat = fixture(); + for i in 0..lat.len() { + assert!(lat.precedes(i, i), "reflexivity at {i}"); + } + } + + #[test] + fn zoom_is_sound_and_complete() { + let lat = fixture(); + // Definitions reachable from the root module = all three. + let defs = lat.zoom(0, SemanticLevel::Definition); + assert_eq!(defs, vec![3, 4, 5]); + // Files reachable from the root module = the two files. + let files = lat.zoom(0, SemanticLevel::File); + assert_eq!(files, vec![1, 2]); + // Definitions reachable from file f1 (id 1) = its two definitions only + // (soundness: connect, in the other file, is excluded). + let f1_defs = lat.zoom(1, SemanticLevel::Definition); + assert_eq!(f1_defs, vec![3, 4]); + } + + #[test] + fn meet_is_lowest_common_ancestor() { + let lat = fixture(); + // meet(login, session) = their file (f1, id 1) + assert_eq!(lat.meet(3, 4), Some(1)); + // meet(login, connect) = the root module (id 0) + assert_eq!(lat.meet(3, 5), Some(0)); + // idempotent + assert_eq!(lat.meet(3, 3), Some(3)); + // commutative + assert_eq!(lat.meet(3, 5), lat.meet(5, 3)); + } + + #[test] + fn empty_lattice_is_well_formed() { + let lat = Lattice::default(); + let cond = lat.condense(); + assert!(cond.is_acyclic()); + assert_eq!(cond.num_components, 0); + assert!(lat.zoom(0, SemanticLevel::File).is_empty()); + assert_eq!(lat.meet(0, 1), None); + } +} diff --git a/src/lib.rs b/src/lib.rs index 4fa306e..1b18c64 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,111 +2,26 @@ // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // // src/lib.rs -// Rust bridge for Git-Reticulator, connecting the Rust CLI/API -// to the core AffineScript lattice logic. +// +// git-reticulator — a semantic-lattice engine for git repositories, and the +// symbolic (layer-1) half of a neuro-symbolic retrieval stack. See +// `.machine_readable/6a2/NEUROSYM.a2ml` for the architecture and +// `PROOF-NEEDS.md` for the formal obligations the engine discharges. +// +// Module map: +// * `lattice` — the pure, dependency-free engine (SCC condensation, partial +// order, LOD zoom, containment meet). Reference core today; designed to be +// swapped for an AffineScript→Wasm core later (ADR-001) without host churn. +// * `ingest` — repository → lattice (std-only filesystem walk). +// * `store` — persistence seam; VeriSimDB octad backend behind `--features verisim`. +// * `api` — actix-web REST surface. #![forbid(unsafe_code)] +pub mod lattice; +pub mod ingest; +pub mod store; + pub mod api { pub mod app; } - -pub mod lattice { - pub mod affine { - // This is a placeholder for the actual AffineScript integration. - // In a real environment, this might use the `affinescript` crate - // to load and execute `.as` files. - - /// Build a semantic lattice for the given repository path and persist it - /// to the given database URI. - /// - /// # Arguments - /// * `repo` - Path or identifier for the git repository to reticulate - /// * `db` - Database URI used to persist the resulting lattice - pub fn build_lattice(repo: &str, db: &str) { - println!("Reticulating repo: {} into DB: {}", repo, db); - // Logic to call src/lattice/affine/lattice.as via affinescript engine - } - - /// Query a previously-built lattice for a semantic zoom target. - /// - /// # Arguments - /// * `zoom` - The semantic node identifier to zoom into - /// * `db` - Database URI of the lattice to query - pub fn query_lattice(zoom: &str, db: &str) { - println!("Zooming into semantic node: {} from DB: {}", zoom, db); - // Logic to call zoom_to_node in src/lattice/affine/lattice.as - } - } -} - -// --------------------------------------------------------------------------- -// Unit + Smoke tests (inline, always compiled with `cargo test`) -// --------------------------------------------------------------------------- -#[cfg(test)] -mod unit_tests { - use super::lattice::affine; - - // --- Unit: function signatures accept basic inputs --- - - /// build_lattice should accept a simple repo path and db URI without panicking. - #[test] - fn unit_build_lattice_basic() { - affine::build_lattice("my-repo", "postgres://localhost/lattice"); - } - - /// query_lattice should accept a zoom node and db URI without panicking. - #[test] - fn unit_query_lattice_basic() { - affine::query_lattice("module::core", "postgres://localhost/lattice"); - } - - // --- Smoke: module-level availability --- - - /// Smoke: the affine module is importable and the two public functions - /// are callable without any setup overhead. - #[test] - fn smoke_affine_module_callable() { - // Simply calling both entry points verifies the module compiles and - // is reachable at runtime (no linker / FFI issues). - affine::build_lattice("smoke-repo", "smoke://db"); - affine::query_lattice("smoke-node", "smoke://db"); - } - - /// Smoke: an empty-but-valid argument set must not panic. - #[test] - fn smoke_empty_strings_do_not_panic() { - // Empty strings represent a degenerate input; the current implementation - // must tolerate them without panicking — even if the output is no-op. - affine::build_lattice("", ""); - affine::query_lattice("", ""); - } - - // --- Unit: argument independence --- - - /// The two functions are independent; calling build_lattice should not - /// alter the behaviour of a subsequent call to query_lattice. - #[test] - fn unit_functions_are_independent() { - affine::build_lattice("repo-a", "db://a"); - affine::query_lattice("node-a", "db://a"); - affine::build_lattice("repo-b", "db://b"); - affine::query_lattice("node-b", "db://b"); - // If we reach here without a panic the invariant holds. - } - - /// Unicode inputs should not cause any runtime panics. - #[test] - fn unit_unicode_inputs() { - affine::build_lattice("репо/тест", "db://юникод"); - affine::query_lattice("узел_семантики", "db://юникод"); - } - - /// Very long strings should not trigger stack overflows or panics. - #[test] - fn unit_long_inputs() { - let long = "x".repeat(4096); - affine::build_lattice(&long, &long); - affine::query_lattice(&long, &long); - } -} diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..72e39bf --- /dev/null +++ b/src/store.rs @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// src/store.rs +// +// Persistence seam for built lattices. The lattice is the engine's output; +// where it is stored is a separate concern behind the `LatticeStore` trait. +// +// VeriSimDB is the database of record (user directive): each keyword node maps +// to one octad, the relationships to the graph modality, the embeddings to the +// vector modality, authorship→provenance, history→temporal. The verisim backend +// is feature-gated (`--features verisim`) so the default build stays +// dependency-light and offline. Federation is intentionally NOT wired — a +// single standalone verisim instance covers one repo's lattice; extend to the +// federation coordinator only if a lattice must span multiple stores. + +use crate::lattice::Lattice; + +/// A backend that can persist a built lattice. `persist` returns the number of +/// nodes the backend accepted. +pub trait LatticeStore { + type Error: std::fmt::Debug; + fn persist(&mut self, lattice: &Lattice) -> Result; +} + +/// In-memory store — the default, always-available backend. Useful for tests, +/// dry runs, and as the reference implementation of the trait. +#[derive(Debug, Default)] +pub struct InMemoryStore { + stored: usize, +} + +impl InMemoryStore { + pub fn new() -> Self { + Self::default() + } + /// Total nodes persisted across all calls. + pub fn stored(&self) -> usize { + self.stored + } +} + +impl LatticeStore for InMemoryStore { + type Error = std::convert::Infallible; + fn persist(&mut self, lattice: &Lattice) -> Result { + self.stored += lattice.len(); + Ok(lattice.len()) + } +} + +/// VeriSimDB octad-store backend (feature `verisim`). Talks to `verisim-api` +/// over HTTP; see the module docs for the modality mapping. +#[cfg(feature = "verisim")] +pub mod verisim { + use crate::lattice::Lattice; + + /// HTTP client for a standalone VeriSimDB instance. + pub struct VerisimStore { + base_url: String, + http: reqwest::Client, + } + + impl VerisimStore { + /// `base_url` is the verisim-api root, e.g. `http://localhost:8080`. + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + http: reqwest::Client::new(), + } + } + + /// Liveness probe against `GET /health`. + pub async fn health(&self) -> Result { + let resp = self + .http + .get(format!("{}/health", self.base_url)) + .send() + .await?; + Ok(resp.status().is_success()) + } + + /// Persist each lattice node as an octad via `POST /api/v1/octads`, + /// returning the count the server accepted. Edges are carried in each + /// octad's graph modality; embeddings in the vector modality. + pub async fn persist(&self, lattice: &Lattice) -> Result { + let mut accepted = 0usize; + for kw in lattice.nodes() { + let relationships: Vec<_> = lattice + .edges() + .iter() + .filter(|e| e.source == kw.id) + .map(|e| { + serde_json::json!({ + "rel_type": e.rel_type, + "target": e.target, + "weight": e.weight, + }) + }) + .collect(); + let body = serde_json::json!({ + "name": kw.name, + "document": { "title": kw.name, "body": kw.file }, + "semantic": { "types": [format!("level:{}", kw.level.as_str())] }, + "vector": kw.embedding, + "graph": { "relationships": relationships }, + }); + let resp = self + .http + .post(format!("{}/api/v1/octads", self.base_url)) + .json(&body) + .send() + .await?; + if resp.status().is_success() { + accepted += 1; + } + } + Ok(accepted) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lattice::{LatticeBuilder, SemanticLevel}; + + #[test] + fn in_memory_store_counts_nodes() { + let mut b = LatticeBuilder::new(); + b.add_keyword("a".into(), "f".into(), SemanticLevel::Module, None); + b.add_keyword("b".into(), "f".into(), SemanticLevel::File, Some(0)); + let lattice = b.build(); + + let mut store = InMemoryStore::new(); + let n = store.persist(&lattice).unwrap(); + assert_eq!(n, 2); + assert_eq!(store.stored(), 2); + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 0bf0699..0fc0ec5 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -78,15 +78,15 @@ fn reflexive_query_lattice_idempotent_on_same_args() { /// assign a meaningful value from a void operation. #[test] fn contract_build_lattice_returns_unit() { - let result: () = affine::build_lattice("contract-repo", "contract://db"); - let _ = result; // type annotation above is the real assertion + // the unit pattern asserts build_lattice returns () + let () = affine::build_lattice("contract-repo", "contract://db"); } /// Contract: query_lattice must always return () for the same reason. #[test] fn contract_query_lattice_returns_unit() { - let result: () = affine::query_lattice("contract-node", "contract://db"); - let _ = result; + // the unit pattern asserts query_lattice returns () + let () = affine::query_lattice("contract-node", "contract://db"); } /// Contract: neither function panics when given the same db URI but different