mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-08-11 13:50:35 +00:00
Merge remote-tracking branch 'github/main'
This commit is contained in:
commit
2d19317660
36 changed files with 3345 additions and 13315 deletions
42
0-Core-Formalism/lean/Semantics/Semantics/AVMIsa/Instr.lean
Normal file
42
0-Core-Formalism/lean/Semantics/Semantics/AVMIsa/Instr.lean
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
-- AVM ISA v1 (Lean-only): Instructions
|
||||||
|
-- Closed-world opcodes. No CALL/IMPORT. No string dispatch.
|
||||||
|
|
||||||
|
import Semantics.AVMIsa.Value
|
||||||
|
|
||||||
|
namespace Semantics.AVMIsa
|
||||||
|
|
||||||
|
/-- Finite primitive set (closed-world).
|
||||||
|
|
||||||
|
If extensibility is needed, add a constructor here and define its semantics in Lean.
|
||||||
|
Backends must implement the same semantics.
|
||||||
|
-/
|
||||||
|
inductive Prim : Type where
|
||||||
|
| addSatQ0
|
||||||
|
| subSatQ0
|
||||||
|
| addSatQ16
|
||||||
|
| subSatQ16
|
||||||
|
| and
|
||||||
|
| or
|
||||||
|
| not
|
||||||
|
deriving DecidableEq, BEq, Inhabited
|
||||||
|
|
||||||
|
/-- Core instruction set.
|
||||||
|
|
||||||
|
`load`/`store` use `Nat` indices in this v1 skeleton.
|
||||||
|
Strict implementations SHOULD replace them with `Fin n` once the local-frame
|
||||||
|
size is part of `Program`.
|
||||||
|
-/
|
||||||
|
inductive Instr : Type where
|
||||||
|
| push : AnyVal → Instr
|
||||||
|
| pop : Instr
|
||||||
|
| dup : Instr
|
||||||
|
| swap : Instr
|
||||||
|
| load : Nat → Instr
|
||||||
|
| store : Nat → Instr
|
||||||
|
| jump : Nat → Instr
|
||||||
|
| jumpIf : Nat → Instr
|
||||||
|
| prim : Prim → Instr
|
||||||
|
| halt : Instr
|
||||||
|
deriving Inhabited
|
||||||
|
|
||||||
|
end Semantics.AVMIsa
|
||||||
44
0-Core-Formalism/lean/Semantics/Semantics/AVMIsa/Run.lean
Normal file
44
0-Core-Formalism/lean/Semantics/Semantics/AVMIsa/Run.lean
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
-- AVM ISA v1 (Lean-only): Run semantics (fuel-bounded)
|
||||||
|
|
||||||
|
import Semantics.AVMIsa.Step
|
||||||
|
|
||||||
|
namespace Semantics.AVMIsa
|
||||||
|
|
||||||
|
/-- Fuel for total execution. -/
|
||||||
|
abbrev Fuel := Nat
|
||||||
|
|
||||||
|
/-- Fuel-bounded run.
|
||||||
|
|
||||||
|
Stops when:
|
||||||
|
- fuel exhausted
|
||||||
|
- machine halted
|
||||||
|
- an error occurs
|
||||||
|
-/
|
||||||
|
def run (fuel : Fuel) (program : List Instr) (s : State) : Outcome State :=
|
||||||
|
match fuel with
|
||||||
|
| 0 => Outcome.ok s
|
||||||
|
| Nat.succ f =>
|
||||||
|
if s.halted then Outcome.ok s
|
||||||
|
else
|
||||||
|
match step program s with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok s1 => run f program s1
|
||||||
|
|
||||||
|
/-- Canary: boolean not.
|
||||||
|
|
||||||
|
#eval should produce `true` on top of stack.
|
||||||
|
-/
|
||||||
|
def canaryNot : List Instr :=
|
||||||
|
[
|
||||||
|
Instr.push ⟨AvmTy.bool, AvmVal.b false⟩,
|
||||||
|
Instr.prim Prim.not,
|
||||||
|
Instr.halt
|
||||||
|
]
|
||||||
|
|
||||||
|
/-- Canary initial state. -/
|
||||||
|
def canaryState : State :=
|
||||||
|
{ pc := 0, stack := [], locals := [], halted := false }
|
||||||
|
|
||||||
|
#eval run 8 canaryNot canaryState
|
||||||
|
|
||||||
|
end Semantics.AVMIsa
|
||||||
31
0-Core-Formalism/lean/Semantics/Semantics/AVMIsa/State.lean
Normal file
31
0-Core-Formalism/lean/Semantics/Semantics/AVMIsa/State.lean
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
-- AVM ISA v1 (Lean-only): State
|
||||||
|
|
||||||
|
import Semantics.AVMIsa.Instr
|
||||||
|
|
||||||
|
namespace Semantics.AVMIsa
|
||||||
|
|
||||||
|
/-- Machine state.
|
||||||
|
|
||||||
|
This is intentionally minimal in v1. It is sufficient to define a total `run`
|
||||||
|
with fuel.
|
||||||
|
-/
|
||||||
|
structure State where
|
||||||
|
pc : Nat
|
||||||
|
stack : List AnyVal
|
||||||
|
locals : List (Option AnyVal)
|
||||||
|
halted : Bool
|
||||||
|
|
||||||
|
deriving Inhabited
|
||||||
|
|
||||||
|
/-- Safe locals lookup (returns `none` when out of bounds). -/
|
||||||
|
def getLocal? (s : State) (i : Nat) : Option AnyVal :=
|
||||||
|
s.locals.getD i none
|
||||||
|
|
||||||
|
/-- Safe locals set (no-op when out of bounds). -/
|
||||||
|
def setLocal (s : State) (i : Nat) (v : AnyVal) : State :=
|
||||||
|
if h : i < s.locals.length then
|
||||||
|
{ s with locals := s.locals.set ⟨i, h⟩ (some v) }
|
||||||
|
else
|
||||||
|
s
|
||||||
|
|
||||||
|
end Semantics.AVMIsa
|
||||||
162
0-Core-Formalism/lean/Semantics/Semantics/AVMIsa/Step.lean
Normal file
162
0-Core-Formalism/lean/Semantics/Semantics/AVMIsa/Step.lean
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
-- AVM ISA v1 (Lean-only): Step semantics
|
||||||
|
|
||||||
|
import Semantics.AVMIsa.State
|
||||||
|
|
||||||
|
namespace Semantics.AVMIsa
|
||||||
|
|
||||||
|
/-- Error tags for rejected states (ill-typed, underflow, etc.). -/
|
||||||
|
inductive StepError : Type where
|
||||||
|
| stackUnderflow
|
||||||
|
| typeMismatch
|
||||||
|
| invalidJump
|
||||||
|
| missingLocal
|
||||||
|
deriving Inhabited, DecidableEq, BEq
|
||||||
|
|
||||||
|
/-- Outcome type for AVM execution.
|
||||||
|
|
||||||
|
We avoid Float and avoid exceptions. Backends should mirror this boundary.
|
||||||
|
-/
|
||||||
|
inductive Outcome (α : Type) : Type where
|
||||||
|
| ok : α → Outcome α
|
||||||
|
| err : StepError → Outcome α
|
||||||
|
deriving Inhabited
|
||||||
|
|
||||||
|
/-- Pop one element from stack. -/
|
||||||
|
def pop1 (s : State) : Outcome (AnyVal × State) :=
|
||||||
|
match s.stack with
|
||||||
|
| [] => Outcome.err StepError.stackUnderflow
|
||||||
|
| x :: xs => Outcome.ok (x, { s with stack := xs })
|
||||||
|
|
||||||
|
/-- Push one element onto stack. -/
|
||||||
|
def push1 (s : State) (v : AnyVal) : State :=
|
||||||
|
{ s with stack := v :: s.stack }
|
||||||
|
|
||||||
|
/-- Evaluate a primitive.
|
||||||
|
|
||||||
|
NOTE: This v1 skeleton implements only a small subset. Extend strictly by
|
||||||
|
adding Lean semantics; do not delegate meaning to backends.
|
||||||
|
-/
|
||||||
|
def evalPrim (p : Prim) (s : State) : Outcome State :=
|
||||||
|
match p with
|
||||||
|
| Prim.not =>
|
||||||
|
match pop1 s with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok (v, s1) =>
|
||||||
|
match v.ty with
|
||||||
|
| AvmTy.bool =>
|
||||||
|
let b := match v.val with | AvmVal.b x => x
|
||||||
|
Outcome.ok (push1 s1 ⟨AvmTy.bool, AvmVal.b (!b)⟩)
|
||||||
|
| _ => Outcome.err StepError.typeMismatch
|
||||||
|
| Prim.and =>
|
||||||
|
match pop1 s with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok (v1, s1) =>
|
||||||
|
match pop1 s1 with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok (v2, s2) =>
|
||||||
|
if v1.ty = AvmTy.bool ∧ v2.ty = AvmTy.bool then
|
||||||
|
let b1 := match v1.val with | AvmVal.b x => x
|
||||||
|
let b2 := match v2.val with | AvmVal.b x => x
|
||||||
|
Outcome.ok (push1 s2 ⟨AvmTy.bool, AvmVal.b (b2 && b1)⟩)
|
||||||
|
else
|
||||||
|
Outcome.err StepError.typeMismatch
|
||||||
|
| Prim.or =>
|
||||||
|
match pop1 s with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok (v1, s1) =>
|
||||||
|
match pop1 s1 with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok (v2, s2) =>
|
||||||
|
if v1.ty = AvmTy.bool ∧ v2.ty = AvmTy.bool then
|
||||||
|
let b1 := match v1.val with | AvmVal.b x => x
|
||||||
|
let b2 := match v2.val with | AvmVal.b x => x
|
||||||
|
Outcome.ok (push1 s2 ⟨AvmTy.bool, AvmVal.b (b2 || b1)⟩)
|
||||||
|
else
|
||||||
|
Outcome.err StepError.typeMismatch
|
||||||
|
| Prim.addSatQ0 =>
|
||||||
|
match pop1 s with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok (v1, s1) =>
|
||||||
|
match pop1 s1 with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok (v2, s2) =>
|
||||||
|
if v1.ty = AvmTy.q0_16 ∧ v2.ty = AvmTy.q0_16 then
|
||||||
|
let x := match v1.val with | AvmVal.q0 q => q
|
||||||
|
let y := match v2.val with | AvmVal.q0 q => q
|
||||||
|
Outcome.ok (push1 s2 ⟨AvmTy.q0_16, AvmVal.q0 (Semantics.Q0_16.addSat y x)⟩)
|
||||||
|
else
|
||||||
|
Outcome.err StepError.typeMismatch
|
||||||
|
| Prim.subSatQ0 =>
|
||||||
|
match pop1 s with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok (v1, s1) =>
|
||||||
|
match pop1 s1 with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok (v2, s2) =>
|
||||||
|
if v1.ty = AvmTy.q0_16 ∧ v2.ty = AvmTy.q0_16 then
|
||||||
|
let x := match v1.val with | AvmVal.q0 q => q
|
||||||
|
let y := match v2.val with | AvmVal.q0 q => q
|
||||||
|
Outcome.ok (push1 s2 ⟨AvmTy.q0_16, AvmVal.q0 (Semantics.Q0_16.subSat y x)⟩)
|
||||||
|
else
|
||||||
|
Outcome.err StepError.typeMismatch
|
||||||
|
| _ =>
|
||||||
|
-- Remaining primitives are not yet implemented in v1.
|
||||||
|
Outcome.err StepError.typeMismatch
|
||||||
|
|
||||||
|
/-- One-step execution.
|
||||||
|
|
||||||
|
`Program` is modeled as a list for v1.
|
||||||
|
-/
|
||||||
|
def step (program : List Instr) (s : State) : Outcome State :=
|
||||||
|
if s.halted then
|
||||||
|
Outcome.ok s
|
||||||
|
else
|
||||||
|
match program.get? s.pc with
|
||||||
|
| none => Outcome.err StepError.invalidJump
|
||||||
|
| some instr =>
|
||||||
|
match instr with
|
||||||
|
| Instr.halt => Outcome.ok { s with halted := true }
|
||||||
|
| Instr.push v => Outcome.ok { s with pc := s.pc + 1, stack := v :: s.stack }
|
||||||
|
| Instr.pop =>
|
||||||
|
match pop1 s with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok (_, s1) => Outcome.ok { s1 with pc := s.pc + 1 }
|
||||||
|
| Instr.dup =>
|
||||||
|
match s.stack with
|
||||||
|
| [] => Outcome.err StepError.stackUnderflow
|
||||||
|
| x :: xs => Outcome.ok { s with pc := s.pc + 1, stack := x :: x :: xs }
|
||||||
|
| Instr.swap =>
|
||||||
|
match s.stack with
|
||||||
|
| a :: b :: xs => Outcome.ok { s with pc := s.pc + 1, stack := b :: a :: xs }
|
||||||
|
| _ => Outcome.err StepError.stackUnderflow
|
||||||
|
| Instr.load i =>
|
||||||
|
match getLocal? s i with
|
||||||
|
| none => Outcome.err StepError.missingLocal
|
||||||
|
| some v => Outcome.ok { s with pc := s.pc + 1, stack := v :: s.stack }
|
||||||
|
| Instr.store i =>
|
||||||
|
match pop1 s with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok (v, s1) => Outcome.ok { (setLocal s1 i v) with pc := s.pc + 1 }
|
||||||
|
| Instr.jump target =>
|
||||||
|
if target < program.length then
|
||||||
|
Outcome.ok { s with pc := target }
|
||||||
|
else
|
||||||
|
Outcome.err StepError.invalidJump
|
||||||
|
| Instr.jumpIf target =>
|
||||||
|
match pop1 s with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok (v, s1) =>
|
||||||
|
if v.ty = AvmTy.bool then
|
||||||
|
let b := match v.val with | AvmVal.b x => x
|
||||||
|
if b then
|
||||||
|
if target < program.length then Outcome.ok { s1 with pc := target } else Outcome.err StepError.invalidJump
|
||||||
|
else
|
||||||
|
Outcome.ok { s1 with pc := s.pc + 1 }
|
||||||
|
else
|
||||||
|
Outcome.err StepError.typeMismatch
|
||||||
|
| Instr.prim p =>
|
||||||
|
match evalPrim p s with
|
||||||
|
| Outcome.err e => Outcome.err e
|
||||||
|
| Outcome.ok s1 => Outcome.ok { s1 with pc := s.pc + 1 }
|
||||||
|
|
||||||
|
end Semantics.AVMIsa
|
||||||
15
0-Core-Formalism/lean/Semantics/Semantics/AVMIsa/Types.lean
Normal file
15
0-Core-Formalism/lean/Semantics/Semantics/AVMIsa/Types.lean
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- AVM ISA v1 (Lean-only): Types
|
||||||
|
-- Core rule: closed-world finite type universe; no Float.
|
||||||
|
|
||||||
|
import Semantics.FixedPoint
|
||||||
|
|
||||||
|
namespace Semantics.AVMIsa
|
||||||
|
|
||||||
|
/-- Finite AVM type universe. Closed-world; extend only by adding constructors. -/
|
||||||
|
inductive AvmTy : Type where
|
||||||
|
| q0_16 : AvmTy
|
||||||
|
| q16_16 : AvmTy
|
||||||
|
| bool : AvmTy
|
||||||
|
deriving DecidableEq, BEq, Inhabited
|
||||||
|
|
||||||
|
end Semantics.AVMIsa
|
||||||
29
0-Core-Formalism/lean/Semantics/Semantics/AVMIsa/Value.lean
Normal file
29
0-Core-Formalism/lean/Semantics/Semantics/AVMIsa/Value.lean
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- AVM ISA v1 (Lean-only): Values
|
||||||
|
-- Values are strictly typed; no dynamic Any.
|
||||||
|
|
||||||
|
import Semantics.AVMIsa.Types
|
||||||
|
import Semantics.FixedPoint
|
||||||
|
|
||||||
|
namespace Semantics.AVMIsa
|
||||||
|
|
||||||
|
/-- Typed value payload. -/
|
||||||
|
inductive AvmVal : AvmTy → Type where
|
||||||
|
| q0 : Semantics.Q0_16 → AvmVal AvmTy.q0_16
|
||||||
|
| q16 : Semantics.Q16_16 → AvmVal AvmTy.q16_16
|
||||||
|
| b : Bool → AvmVal AvmTy.bool
|
||||||
|
|
||||||
|
/-- Existential wrapper for storing values in an untyped container.
|
||||||
|
|
||||||
|
We use this in the ISA skeleton to keep the state representation simple.
|
||||||
|
Typing is enforced by an explicit type-check pass and by instruction semantics
|
||||||
|
that can reject ill-typed stacks.
|
||||||
|
|
||||||
|
Later upgrade path: replace with a fully typed stack representation.
|
||||||
|
-/
|
||||||
|
structure AnyVal where
|
||||||
|
ty : AvmTy
|
||||||
|
val : AvmVal ty
|
||||||
|
|
||||||
|
deriving Inhabited
|
||||||
|
|
||||||
|
end Semantics.AVMIsa
|
||||||
120
0-Core-Formalism/lean/Semantics/Semantics/EneContextSurface.lean
Normal file
120
0-Core-Formalism/lean/Semantics/Semantics/EneContextSurface.lean
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import Semantics.McpSurfaceManifest
|
||||||
|
|
||||||
|
namespace Semantics.EneContextSurface
|
||||||
|
|
||||||
|
open Semantics.McpSurfaceManifest
|
||||||
|
|
||||||
|
/--
|
||||||
|
Lean-owned MCP surface for ENE context memory operations.
|
||||||
|
|
||||||
|
This file is *surface only* (names + schemas + descriptions). Runtime shims must not
|
||||||
|
invent alternate tools; they should load and expose this manifest.
|
||||||
|
|
||||||
|
Execution plumbing is provided by non-Lean runtimes (Rust) and must be keyed off
|
||||||
|
these tool names.
|
||||||
|
-/
|
||||||
|
|
||||||
|
def eneStatus : ToolSpec :=
|
||||||
|
{ name := "ene_status"
|
||||||
|
description := "ENE context health: local memory ledger, ENE API, session-sync binary."
|
||||||
|
inputSchema := Json.obj [] }
|
||||||
|
|
||||||
|
def eneRemember : ToolSpec :=
|
||||||
|
{ name := "ene_remember"
|
||||||
|
description := "Store a durable ENE memory packet with a hash-chain receipt."
|
||||||
|
inputSchema := Json.obj
|
||||||
|
[ ("type", "object")
|
||||||
|
, ("properties", Json.obj
|
||||||
|
[ ("agent", Json.obj [("type", "string"), ("default", "codex")])
|
||||||
|
, ("key", Json.obj [("type", "string")])
|
||||||
|
, ("value", Json.obj [])
|
||||||
|
, ("tags", Json.obj [("type", "array"), ("items", Json.obj [("type", "string")])])
|
||||||
|
, ("kind", Json.obj [("type", "string"), ("default", "note")])
|
||||||
|
, ("source", Json.obj [("type", "string"), ("default", "mcp")])
|
||||||
|
])
|
||||||
|
, ("required", Json.arr #["key", "value"])
|
||||||
|
] }
|
||||||
|
|
||||||
|
def eneRecall : ToolSpec :=
|
||||||
|
{ name := "ene_recall"
|
||||||
|
description := "Recall ENE memory by exact key or query over local memory."
|
||||||
|
inputSchema := Json.obj
|
||||||
|
[ ("type", "object")
|
||||||
|
, ("properties", Json.obj
|
||||||
|
[ ("agent", Json.obj [("type", "string"), ("default", "codex")])
|
||||||
|
, ("key", Json.obj [("type", "string")])
|
||||||
|
, ("query", Json.obj [("type", "string")])
|
||||||
|
, ("limit", Json.obj [("type", "integer"), ("default", 10)])
|
||||||
|
])
|
||||||
|
] }
|
||||||
|
|
||||||
|
def eneSearch : ToolSpec :=
|
||||||
|
{ name := "ene_search"
|
||||||
|
description := "Search ENE API chat/session memory plus local ENE memory ledger."
|
||||||
|
inputSchema := Json.obj
|
||||||
|
[ ("type", "object")
|
||||||
|
, ("properties", Json.obj
|
||||||
|
[ ("query", Json.obj [("type", "string")])
|
||||||
|
, ("limit", Json.obj [("type", "integer"), ("default", 10)])
|
||||||
|
, ("semantic", Json.obj [("type", "boolean"), ("default", false)])
|
||||||
|
, ("agent", Json.obj [("type", "string"), ("default", "codex")])
|
||||||
|
, ("sources", Json.obj [("type", "array"), ("items", Json.obj [("type", "string")])])
|
||||||
|
])
|
||||||
|
, ("required", Json.arr #["query"])
|
||||||
|
] }
|
||||||
|
|
||||||
|
def eneContext : ToolSpec :=
|
||||||
|
{ name := "ene_context"
|
||||||
|
description := "ENE-first startup/context packet: status, search, recall, optional transcript save."
|
||||||
|
inputSchema := Json.obj
|
||||||
|
[ ("type", "object")
|
||||||
|
, ("properties", Json.obj
|
||||||
|
[ ("user_message", Json.obj [("type", "string")])
|
||||||
|
, ("query", Json.obj [("type", "string")])
|
||||||
|
, ("agent", Json.obj [("type", "string"), ("default", "codex")])
|
||||||
|
, ("session_id", Json.obj [("type", "string")])
|
||||||
|
, ("save_exchange", Json.obj [("type", "boolean"), ("default", false)])
|
||||||
|
, ("limit", Json.obj [("type", "integer"), ("default", 10)])
|
||||||
|
])
|
||||||
|
] }
|
||||||
|
|
||||||
|
def eneSessions : ToolSpec :=
|
||||||
|
{ name := "ene_sessions"
|
||||||
|
description := "List or fetch ENE chat sessions through ene-api."
|
||||||
|
inputSchema := Json.obj
|
||||||
|
[ ("type", "object")
|
||||||
|
, ("properties", Json.obj
|
||||||
|
[ ("action", Json.obj [("type", "string"), ("default", "list")])
|
||||||
|
, ("session_id", Json.obj [("type", "string")])
|
||||||
|
, ("limit", Json.obj [("type", "integer"), ("default", 10)])
|
||||||
|
])
|
||||||
|
] }
|
||||||
|
|
||||||
|
def eneSync : ToolSpec :=
|
||||||
|
{ name := "ene_sync"
|
||||||
|
description := "Run or dry-run ene-session-sync commands."
|
||||||
|
inputSchema := Json.obj
|
||||||
|
[ ("type", "object")
|
||||||
|
, ("properties", Json.obj
|
||||||
|
[ ("command", Json.obj [("type", "string"), ("default", "list")])
|
||||||
|
, ("dry_run", Json.obj [("type", "boolean"), ("default", true)])
|
||||||
|
, ("limit", Json.obj [("type", "integer"), ("default", 20)])
|
||||||
|
, ("embed", Json.obj [("type", "boolean"), ("default", false)])
|
||||||
|
])
|
||||||
|
] }
|
||||||
|
|
||||||
|
def tools : List ToolSpec :=
|
||||||
|
[ eneStatus
|
||||||
|
, eneRemember
|
||||||
|
, eneRecall
|
||||||
|
, eneSearch
|
||||||
|
, eneContext
|
||||||
|
, eneSessions
|
||||||
|
, eneSync
|
||||||
|
]
|
||||||
|
|
||||||
|
/-- JSON `{ tools: [...] }` payload for MCP `tools/list`. -/
|
||||||
|
def toolsJson : Json :=
|
||||||
|
toJsonToolsList tools
|
||||||
|
|
||||||
|
end Semantics.EneContextSurface
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import Semantics.JsonLSurfaceConnector
|
||||||
|
import Lean.Data.Json
|
||||||
|
|
||||||
|
namespace Semantics.McpSurfaceManifest
|
||||||
|
|
||||||
|
open Lean
|
||||||
|
open Semantics.JsonLSurfaceConnector
|
||||||
|
|
||||||
|
/-!
|
||||||
|
Lean-first MCP surface manifest.
|
||||||
|
|
||||||
|
This file defines the *published* tool list and per-tool input schemas for the
|
||||||
|
JsonL surface connector. Shims may expose these tools over MCP, but MUST NOT
|
||||||
|
invent tool names or schemas.
|
||||||
|
-/
|
||||||
|
|
||||||
|
structure JsonSchema where
|
||||||
|
schema : Json
|
||||||
|
deriving Repr, DecidableEq
|
||||||
|
|
||||||
|
structure ToolSpec where
|
||||||
|
name : String
|
||||||
|
description : String
|
||||||
|
inputSchema : JsonSchema
|
||||||
|
deriving Repr, DecidableEq
|
||||||
|
|
||||||
|
/-- Minimal JSON schema for a single JsonL line payload. -/
|
||||||
|
def jsonlLineSchema : JsonSchema :=
|
||||||
|
{ schema := Json.mkObj [
|
||||||
|
("type", Json.str "object"),
|
||||||
|
("properties", Json.mkObj [
|
||||||
|
("line", Json.mkObj [("type", Json.str "string")])
|
||||||
|
]),
|
||||||
|
("required", Json.arr #[Json.str "line"]),
|
||||||
|
("additionalProperties", Json.bool false)
|
||||||
|
] }
|
||||||
|
|
||||||
|
/-- Minimal JSON schema for a connector-health request. -/
|
||||||
|
def connectorHealthSchema : JsonSchema :=
|
||||||
|
{ schema := Json.mkObj [
|
||||||
|
("type", Json.str "object"),
|
||||||
|
("properties", Json.mkObj []),
|
||||||
|
("additionalProperties", Json.bool false)
|
||||||
|
] }
|
||||||
|
|
||||||
|
/-- Tool specs indexed by the Lean McpTool enum. -/
|
||||||
|
def toolSpec : McpTool → ToolSpec
|
||||||
|
| .appendJsonL =>
|
||||||
|
{ name := McpTool.toName .appendJsonL
|
||||||
|
description := "Append one JSONL event line to the configured surface target."
|
||||||
|
inputSchema := jsonlLineSchema }
|
||||||
|
| .attestJsonL =>
|
||||||
|
{ name := McpTool.toName .attestJsonL
|
||||||
|
description := "Attest a JSONL event line (deterministic hash + provenance chain policy)."
|
||||||
|
inputSchema := jsonlLineSchema }
|
||||||
|
| .surfaceSync =>
|
||||||
|
{ name := McpTool.toName .surfaceSync
|
||||||
|
description := "Trigger a surface sync for a configured target (adapter-owned transport; Lean-owned policy)."
|
||||||
|
inputSchema := connectorHealthSchema }
|
||||||
|
| .connectorHealth =>
|
||||||
|
{ name := McpTool.toName .connectorHealth
|
||||||
|
description := "Return connector manifest + readiness state."
|
||||||
|
inputSchema := connectorHealthSchema }
|
||||||
|
|
||||||
|
/-- Published tool list for this instance. -/
|
||||||
|
def publishedTools (c : SurfaceConnector := instanceConnector) : List ToolSpec :=
|
||||||
|
c.tools.map toolSpec
|
||||||
|
|
||||||
|
/-- Emit the MCP `tools/list` JSON payload. -/
|
||||||
|
def toJsonToolsList (tools : List ToolSpec) : Json :=
|
||||||
|
Json.mkObj [
|
||||||
|
("tools", Json.arr <|
|
||||||
|
(tools.map (fun t =>
|
||||||
|
Json.mkObj [
|
||||||
|
("name", Json.str t.name),
|
||||||
|
("description", Json.str t.description),
|
||||||
|
("inputSchema", t.inputSchema.schema)
|
||||||
|
])).toArray
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
/-- The manifest JSON for the current instance connector. -/
|
||||||
|
def instanceToolsJson : Json :=
|
||||||
|
toJsonToolsList (publishedTools instanceConnector)
|
||||||
|
|
||||||
|
-- Witness: tool list must include connector_health.
|
||||||
|
theorem toolsIncludeConnectorHealth :
|
||||||
|
(publishedTools instanceConnector).any (fun t => t.name = "connector_health") = true := by
|
||||||
|
rfl
|
||||||
|
|
||||||
|
#eval instanceToolsJson
|
||||||
|
|
||||||
|
end Semantics.McpSurfaceManifest
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
//! ENE context tool dispatch (shim-only).
|
||||||
|
//!
|
||||||
|
//! Lean owns the *surface* (tool names + schemas). This module implements a
|
||||||
|
//! minimal execution adapter: dispatch each ENE context tool call to a
|
||||||
|
//! command provided via environment variables.
|
||||||
|
//!
|
||||||
|
//! This keeps Rust lean-first compliant:
|
||||||
|
//! - no semantics in Rust
|
||||||
|
//! - no invented tools
|
||||||
|
//! - execution is purely boundary I/O
|
||||||
|
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use serde_json::Value as JsonValue;
|
||||||
|
|
||||||
|
/// All ENE context tool names (must match Lean `Semantics.EneContextSurface`).
|
||||||
|
pub const ENE_CONTEXT_TOOL_NAMES: &[&str] = &[
|
||||||
|
"ene_status",
|
||||||
|
"ene_remember",
|
||||||
|
"ene_recall",
|
||||||
|
"ene_search",
|
||||||
|
"ene_context",
|
||||||
|
"ene_sessions",
|
||||||
|
"ene_sync",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn env_key_for_tool(tool_name: &str) -> Option<&'static str> {
|
||||||
|
match tool_name {
|
||||||
|
"ene_status" => Some("CLAW_ENE_STATUS_CMD"),
|
||||||
|
"ene_remember" => Some("CLAW_ENE_REMEMBER_CMD"),
|
||||||
|
"ene_recall" => Some("CLAW_ENE_RECALL_CMD"),
|
||||||
|
"ene_search" => Some("CLAW_ENE_SEARCH_CMD"),
|
||||||
|
"ene_context" => Some("CLAW_ENE_CONTEXT_CMD"),
|
||||||
|
"ene_sessions" => Some("CLAW_ENE_SESSIONS_CMD"),
|
||||||
|
"ene_sync" => Some("CLAW_ENE_SYNC_CMD"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_cmd(json: &str, env_key: &str) -> Result<Vec<String>, String> {
|
||||||
|
serde_json::from_str::<Vec<String>>(json)
|
||||||
|
.map_err(|error| format!("{env_key} must be JSON array of strings: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch a Lean-defined ENE context tool call.
|
||||||
|
///
|
||||||
|
/// Each tool is executed by spawning a command specified as a JSON argv array
|
||||||
|
/// in an env var. The tool `arguments` JSON is written to stdin.
|
||||||
|
///
|
||||||
|
/// The command must emit either:
|
||||||
|
/// - JSON on stdout (preferred), or
|
||||||
|
/// - plain text (which will be wrapped).
|
||||||
|
///
|
||||||
|
/// Return value is always a string (the MCP server wraps it as text content).
|
||||||
|
pub fn dispatch(tool_name: &str, arguments: &JsonValue) -> Result<String, String> {
|
||||||
|
let env_key = env_key_for_tool(tool_name)
|
||||||
|
.ok_or_else(|| format!("unknown ENE context tool: {tool_name}"))?;
|
||||||
|
|
||||||
|
let cmd_json = std::env::var(env_key)
|
||||||
|
.map_err(|_| format!("missing env var {env_key} for tool {tool_name}"))?;
|
||||||
|
let argv = parse_cmd(&cmd_json, env_key)?;
|
||||||
|
if argv.is_empty() {
|
||||||
|
return Err(format!("{env_key} is empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = Command::new(&argv[0]);
|
||||||
|
if argv.len() > 1 {
|
||||||
|
cmd.args(&argv[1..]);
|
||||||
|
}
|
||||||
|
cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
|
||||||
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|error| format!("failed to spawn tool command for {tool_name}: {error}"))?;
|
||||||
|
|
||||||
|
if let Some(stdin) = child.stdin.as_mut() {
|
||||||
|
use std::io::Write;
|
||||||
|
let payload = serde_json::to_vec(arguments)
|
||||||
|
.map_err(|error| format!("failed to serialize tool arguments: {error}"))?;
|
||||||
|
stdin
|
||||||
|
.write_all(&payload)
|
||||||
|
.map_err(|error| format!("failed to write tool stdin: {error}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = child
|
||||||
|
.wait_with_output()
|
||||||
|
.map_err(|error| format!("failed to wait for tool {tool_name}: {error}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"tool {tool_name} failed (status={}): {}",
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if stdout.is_empty() {
|
||||||
|
return Ok("{}".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_list_is_stable() {
|
||||||
|
assert!(ENE_CONTEXT_TOOL_NAMES.contains(&"ene_status"));
|
||||||
|
assert!(env_key_for_tool("ene_status").is_some());
|
||||||
|
assert!(env_key_for_tool("nope").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
//! Lean-first MCP manifest loader.
|
||||||
|
//!
|
||||||
|
//! This is a boundary shim: it loads the published MCP tool list from a Lean-owned
|
||||||
|
//! JSON manifest (generated by Lean).
|
||||||
|
//!
|
||||||
|
//! Semantics are NOT defined here: this module only parses JSON into runtime
|
||||||
|
//! `McpTool` descriptors.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use serde_json::Value as JsonValue;
|
||||||
|
|
||||||
|
use crate::mcp_stdio::McpTool;
|
||||||
|
|
||||||
|
/// Load tool descriptors from a JSON value shaped like `{ "tools": [...] }`.
|
||||||
|
pub fn tools_from_json(value: &JsonValue) -> Result<Vec<McpTool>, String> {
|
||||||
|
let tools = value
|
||||||
|
.get("tools")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.ok_or_else(|| "Lean MCP manifest missing 'tools' array".to_string())?;
|
||||||
|
|
||||||
|
let mut parsed = Vec::with_capacity(tools.len());
|
||||||
|
for tool in tools {
|
||||||
|
let name = tool
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| "Lean MCP manifest tool missing 'name'".to_string())?
|
||||||
|
.to_string();
|
||||||
|
let description = tool
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let input_schema = tool.get("inputSchema").cloned();
|
||||||
|
|
||||||
|
parsed.push(McpTool {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
input_schema,
|
||||||
|
annotations: None,
|
||||||
|
meta: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run an external command that prints the Lean-owned tools list JSON to stdout.
|
||||||
|
///
|
||||||
|
/// The command is provided as a vector to avoid shell injection.
|
||||||
|
pub fn tools_from_lean_command(command: &[String]) -> Result<Vec<McpTool>, String> {
|
||||||
|
if command.is_empty() {
|
||||||
|
return Err("Lean MCP manifest command is empty".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = Command::new(&command[0]);
|
||||||
|
if command.len() > 1 {
|
||||||
|
cmd.args(&command[1..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|error| format!("failed to spawn Lean MCP manifest command: {error}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"Lean MCP manifest command failed (status={}): {}",
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: JsonValue = serde_json::from_slice(&output.stdout)
|
||||||
|
.map_err(|error| format!("failed to parse Lean MCP manifest JSON: {error}"))?;
|
||||||
|
|
||||||
|
tools_from_json(&json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the Lean manifest using environment variables.
|
||||||
|
///
|
||||||
|
/// - `CLAW_LEAN_MCP_MANIFEST_CMD`: a JSON array of strings.
|
||||||
|
///
|
||||||
|
/// Example (ENE context surface):
|
||||||
|
/// `["bash","-lc","cd 0-Core-Formalism/otom/tools/lean/Semantics && lake env lean -R Semantics -E 'import Semantics.EneContextSurface; open Semantics.EneContextSurface; #eval toolsJson'"]`
|
||||||
|
///
|
||||||
|
/// This stays shim-only: the env var provides *how* to invoke Lean; the manifest
|
||||||
|
/// itself is the tool truth.
|
||||||
|
pub fn tools_from_env() -> Result<Option<Vec<McpTool>>, String> {
|
||||||
|
let cmd = std::env::var("CLAW_LEAN_MCP_MANIFEST_CMD").ok();
|
||||||
|
let Some(cmd) = cmd else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let command: Vec<String> = serde_json::from_str(&cmd)
|
||||||
|
.map_err(|error| format!("CLAW_LEAN_MCP_MANIFEST_CMD must be JSON array: {error}"))?;
|
||||||
|
|
||||||
|
Ok(Some(tools_from_lean_command(&command)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_tools_from_lean_json_shape() {
|
||||||
|
let manifest = json!({
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "connector_health",
|
||||||
|
"description": "Return connector manifest + readiness state.",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let tools = tools_from_json(&manifest).expect("tools parse");
|
||||||
|
assert_eq!(tools.len(), 1);
|
||||||
|
assert_eq!(tools[0].name, "connector_health");
|
||||||
|
assert!(tools[0].input_schema.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,9 @@ mod mcp_client;
|
||||||
pub mod mcp_lifecycle_hardened;
|
pub mod mcp_lifecycle_hardened;
|
||||||
pub mod mcp_server;
|
pub mod mcp_server;
|
||||||
mod mcp_stdio;
|
mod mcp_stdio;
|
||||||
|
mod lean_mcp_manifest;
|
||||||
|
mod ene_context_tools;
|
||||||
|
mod mcp_surface_router;
|
||||||
pub mod mcp_tool_bridge;
|
pub mod mcp_tool_bridge;
|
||||||
mod oauth;
|
mod oauth;
|
||||||
pub mod permission_enforcer;
|
pub mod permission_enforcer;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ use tokio::io::{
|
||||||
stdin, stdout, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, Stdin, Stdout,
|
stdin, stdout, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, Stdin, Stdout,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::lean_mcp_manifest;
|
||||||
use crate::mcp_stdio::{
|
use crate::mcp_stdio::{
|
||||||
JsonRpcError, JsonRpcId, JsonRpcRequest, JsonRpcResponse, McpInitializeResult,
|
JsonRpcError, JsonRpcId, JsonRpcRequest, JsonRpcResponse, McpInitializeResult,
|
||||||
McpInitializeServerInfo, McpListToolsResult, McpTool, McpToolCallContent, McpToolCallParams,
|
McpInitializeServerInfo, McpListToolsResult, McpTool, McpToolCallContent, McpToolCallParams,
|
||||||
|
|
@ -53,11 +54,28 @@ pub struct McpServerSpec {
|
||||||
/// response.
|
/// response.
|
||||||
pub server_version: String,
|
pub server_version: String,
|
||||||
/// Tool descriptors returned for `tools/list`.
|
/// Tool descriptors returned for `tools/list`.
|
||||||
|
///
|
||||||
|
/// If empty, the server will attempt to load the tool list from a Lean-owned
|
||||||
|
/// manifest via `CLAW_LEAN_MCP_MANIFEST_CMD`.
|
||||||
pub tools: Vec<McpTool>,
|
pub tools: Vec<McpTool>,
|
||||||
/// Handler invoked for `tools/call`.
|
/// Handler invoked for `tools/call`.
|
||||||
pub tool_handler: ToolCallHandler,
|
pub tool_handler: ToolCallHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl McpServerSpec {
|
||||||
|
fn resolved_tools(&self) -> Vec<McpTool> {
|
||||||
|
if !self.tools.is_empty() {
|
||||||
|
return self.tools.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
match lean_mcp_manifest::tools_from_env() {
|
||||||
|
Ok(Some(tools)) => tools,
|
||||||
|
Ok(None) => Vec::new(),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Minimal MCP stdio server.
|
/// Minimal MCP stdio server.
|
||||||
///
|
///
|
||||||
/// The server runs a blocking read/dispatch/write loop over the current
|
/// The server runs a blocking read/dispatch/write loop over the current
|
||||||
|
|
@ -178,7 +196,7 @@ impl McpServer {
|
||||||
|
|
||||||
fn handle_tools_list(&self, id: JsonRpcId) -> JsonRpcResponse<JsonValue> {
|
fn handle_tools_list(&self, id: JsonRpcId) -> JsonRpcResponse<JsonValue> {
|
||||||
let result = McpListToolsResult {
|
let result = McpListToolsResult {
|
||||||
tools: self.spec.tools.clone(),
|
tools: self.spec.resolved_tools(),
|
||||||
next_cursor: None,
|
next_cursor: None,
|
||||||
};
|
};
|
||||||
JsonRpcResponse {
|
JsonRpcResponse {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
//! Generic execution router for Lean-owned MCP tool surfaces.
|
||||||
|
//!
|
||||||
|
//! Lean owns tool names + schemas. This module provides a *generic* shim to
|
||||||
|
//! execute Lean-defined tools by dispatching to commands declared in an
|
||||||
|
//! environment-controlled map.
|
||||||
|
//!
|
||||||
|
//! - No tool semantics live here.
|
||||||
|
//! - Routing is data-driven (`CLAW_MCP_TOOL_CMD_MAP`).
|
||||||
|
//! - Commands receive the tool `arguments` JSON on stdin.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use serde_json::Value as JsonValue;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ToolCommandMap {
|
||||||
|
map: BTreeMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolCommandMap {
|
||||||
|
pub fn from_env() -> Result<Option<Self>, String> {
|
||||||
|
let raw = std::env::var("CLAW_MCP_TOOL_CMD_MAP").ok();
|
||||||
|
let Some(raw) = raw else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let map: BTreeMap<String, Vec<String>> = serde_json::from_str(&raw)
|
||||||
|
.map_err(|error| format!("CLAW_MCP_TOOL_CMD_MAP must be JSON object: {error}"))?;
|
||||||
|
Ok(Some(Self { map }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn command_for(&self, tool_name: &str) -> Option<&[String]> {
|
||||||
|
self.map.get(tool_name).map(Vec::as_slice)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handles(&self, tool_name: &str) -> bool {
|
||||||
|
self.map.contains_key(tool_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch_with_map(
|
||||||
|
map: &ToolCommandMap,
|
||||||
|
tool_name: &str,
|
||||||
|
arguments: &JsonValue,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let argv = map
|
||||||
|
.command_for(tool_name)
|
||||||
|
.ok_or_else(|| format!("no command mapping for tool: {tool_name}"))?;
|
||||||
|
if argv.is_empty() {
|
||||||
|
return Err(format!("empty command mapping for tool: {tool_name}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = Command::new(&argv[0]);
|
||||||
|
if argv.len() > 1 {
|
||||||
|
cmd.args(&argv[1..]);
|
||||||
|
}
|
||||||
|
cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
|
||||||
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|error| format!("failed to spawn mapped command for {tool_name}: {error}"))?;
|
||||||
|
|
||||||
|
if let Some(stdin) = child.stdin.as_mut() {
|
||||||
|
use std::io::Write;
|
||||||
|
let payload = serde_json::to_vec(arguments)
|
||||||
|
.map_err(|error| format!("failed to serialize tool arguments: {error}"))?;
|
||||||
|
stdin
|
||||||
|
.write_all(&payload)
|
||||||
|
.map_err(|error| format!("failed to write tool stdin: {error}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = child
|
||||||
|
.wait_with_output()
|
||||||
|
.map_err(|error| format!("failed to wait for tool {tool_name}: {error}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"mapped tool {tool_name} failed (status={}): {}",
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if stdout.is_empty() {
|
||||||
|
return Ok("{}".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_map_parses() {
|
||||||
|
let _lock = crate::test_env_lock();
|
||||||
|
std::env::set_var(
|
||||||
|
"CLAW_MCP_TOOL_CMD_MAP",
|
||||||
|
r#"{ "ene_status": ["echo", "ok"] }"#,
|
||||||
|
);
|
||||||
|
let map = ToolCommandMap::from_env().expect("map load").expect("present");
|
||||||
|
assert!(map.handles("ene_status"));
|
||||||
|
assert!(!map.handles("missing"));
|
||||||
|
std::env::remove_var("CLAW_MCP_TOOL_CMD_MAP");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,86 @@
|
||||||
|
-- Canonical ENE chat/session sync schema
|
||||||
|
--
|
||||||
|
-- This schema is consumed by:
|
||||||
|
-- - 4-Infrastructure/infra/ene-session-sync (Rust)
|
||||||
|
--
|
||||||
|
-- Policy:
|
||||||
|
-- - treat this file as the canonical DDL source for these tables
|
||||||
|
-- - code should not embed divergent CREATE TABLE strings
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS ene;
|
||||||
|
|
||||||
|
-- Optional extension (non-fatal if unavailable).
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ene.chat_sessions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
agent TEXT,
|
||||||
|
model TEXT,
|
||||||
|
workspace_fingerprint TEXT,
|
||||||
|
workspace_root TEXT,
|
||||||
|
fork_parent_session_id TEXT,
|
||||||
|
compaction_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
compaction_summary TEXT,
|
||||||
|
message_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
token_input_total BIGINT NOT NULL DEFAULT 0,
|
||||||
|
token_output_total BIGINT NOT NULL DEFAULT 0,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
first_message_at_ms BIGINT,
|
||||||
|
last_message_at_ms BIGINT,
|
||||||
|
embedding vector(768),
|
||||||
|
meta JSONB NOT NULL DEFAULT '{}',
|
||||||
|
receipt TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ene.chat_messages (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL REFERENCES ene.chat_sessions(session_id) ON DELETE CASCADE,
|
||||||
|
message_index INTEGER NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
blocks JSONB NOT NULL,
|
||||||
|
text_content TEXT,
|
||||||
|
token_input BIGINT NOT NULL DEFAULT 0,
|
||||||
|
token_output BIGINT NOT NULL DEFAULT 0,
|
||||||
|
token_cache_creation BIGINT NOT NULL DEFAULT 0,
|
||||||
|
token_cache_read BIGINT NOT NULL DEFAULT 0,
|
||||||
|
tool_calls JSONB NOT NULL DEFAULT '[]',
|
||||||
|
embedding vector(768),
|
||||||
|
receipt_hash TEXT,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(session_id, message_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ene.ingestion_receipts (
|
||||||
|
receipt_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
shim_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
record_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
source_path TEXT NOT NULL,
|
||||||
|
meta JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Idempotent migration helpers.
|
||||||
|
ALTER TABLE ene.chat_sessions ADD COLUMN IF NOT EXISTS title TEXT;
|
||||||
|
ALTER TABLE ene.chat_sessions ADD COLUMN IF NOT EXISTS agent TEXT;
|
||||||
|
ALTER TABLE ene.chat_sessions ADD COLUMN IF NOT EXISTS model TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_sessions_updated
|
||||||
|
ON ene.chat_sessions(updated_at_ms DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_sessions_workspace
|
||||||
|
ON ene.chat_sessions(workspace_fingerprint);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_order
|
||||||
|
ON ene.chat_messages(session_id, message_index);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_messages_receipt
|
||||||
|
ON ene.chat_messages(receipt_hash);
|
||||||
|
|
||||||
|
-- Optional indexes (non-fatal if missing language config).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_messages_text_search
|
||||||
|
ON ene.chat_messages USING GIN(to_tsvector('english', COALESCE(text_content, '')));
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_messages_tool_search
|
||||||
|
ON ene.chat_messages USING GIN(tool_calls jsonb_path_ops);
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use crate::models::{ChatMessage, ChatSession, IngestionReceipt};
|
use crate::models::{ChatMessage, ChatSession, IngestionReceipt};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use std::path::Path;
|
||||||
use tokio_postgres::{Client, Config, NoTls};
|
use tokio_postgres::{Client, Config, NoTls};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
|
@ -54,93 +55,13 @@ impl RdsSink {
|
||||||
|
|
||||||
/// Create tables and indexes if they do not exist.
|
/// Create tables and indexes if they do not exist.
|
||||||
async fn init_tables(&self) -> Result<()> {
|
async fn init_tables(&self) -> Result<()> {
|
||||||
// Try to enable pgvector — harmless if already enabled or unavailable.
|
// Canonical DDL lives in sql/ene_chat_schema.sql.
|
||||||
let _ = self
|
// Keeping it out of Rust source reduces schema drift across services.
|
||||||
.client
|
let ddl_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("sql/ene_chat_schema.sql");
|
||||||
.batch_execute("CREATE EXTENSION IF NOT EXISTS vector")
|
let ddl = std::fs::read_to_string(&ddl_path)
|
||||||
.await;
|
.with_context(|| format!("read ENE chat schema DDL at {:?}", ddl_path))?;
|
||||||
|
|
||||||
let ddl = r#"
|
self.client.batch_execute(&ddl).await.context("init DDL")?;
|
||||||
CREATE TABLE IF NOT EXISTS ene.chat_sessions (
|
|
||||||
session_id TEXT PRIMARY KEY,
|
|
||||||
title TEXT,
|
|
||||||
agent TEXT,
|
|
||||||
model TEXT,
|
|
||||||
workspace_fingerprint TEXT,
|
|
||||||
workspace_root TEXT,
|
|
||||||
fork_parent_session_id TEXT,
|
|
||||||
compaction_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
compaction_summary TEXT,
|
|
||||||
message_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
token_input_total BIGINT NOT NULL DEFAULT 0,
|
|
||||||
token_output_total BIGINT NOT NULL DEFAULT 0,
|
|
||||||
created_at_ms BIGINT NOT NULL,
|
|
||||||
updated_at_ms BIGINT NOT NULL,
|
|
||||||
first_message_at_ms BIGINT,
|
|
||||||
last_message_at_ms BIGINT,
|
|
||||||
embedding vector(768),
|
|
||||||
meta JSONB NOT NULL DEFAULT '{}',
|
|
||||||
receipt TEXT,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ene.chat_messages (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
session_id TEXT NOT NULL REFERENCES ene.chat_sessions(session_id) ON DELETE CASCADE,
|
|
||||||
message_index INTEGER NOT NULL,
|
|
||||||
role TEXT NOT NULL,
|
|
||||||
blocks JSONB NOT NULL,
|
|
||||||
text_content TEXT,
|
|
||||||
token_input BIGINT NOT NULL DEFAULT 0,
|
|
||||||
token_output BIGINT NOT NULL DEFAULT 0,
|
|
||||||
token_cache_creation BIGINT NOT NULL DEFAULT 0,
|
|
||||||
token_cache_read BIGINT NOT NULL DEFAULT 0,
|
|
||||||
tool_calls JSONB NOT NULL DEFAULT '[]',
|
|
||||||
embedding vector(768),
|
|
||||||
receipt_hash TEXT,
|
|
||||||
created_at_ms BIGINT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE(session_id, message_index)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ene.ingestion_receipts (
|
|
||||||
receipt_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
shim_name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
sha256 TEXT NOT NULL,
|
|
||||||
record_count BIGINT NOT NULL DEFAULT 0,
|
|
||||||
source_path TEXT NOT NULL,
|
|
||||||
meta JSONB NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Columns may already exist after a schema migration; ADD COLUMN IF NOT EXISTS
|
|
||||||
-- is idempotent on PostgreSQL ≥ 9.6.
|
|
||||||
ALTER TABLE ene.chat_sessions ADD COLUMN IF NOT EXISTS title TEXT;
|
|
||||||
ALTER TABLE ene.chat_sessions ADD COLUMN IF NOT EXISTS agent TEXT;
|
|
||||||
ALTER TABLE ene.chat_sessions ADD COLUMN IF NOT EXISTS model TEXT;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chat_sessions_updated
|
|
||||||
ON ene.chat_sessions(updated_at_ms DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chat_sessions_workspace
|
|
||||||
ON ene.chat_sessions(workspace_fingerprint);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_order
|
|
||||||
ON ene.chat_messages(session_id, message_index);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_receipt
|
|
||||||
ON ene.chat_messages(receipt_hash);
|
|
||||||
"#;
|
|
||||||
// Full-text and JSONB indexes require non-empty text_content / tool_calls;
|
|
||||||
// create them separately so a pgvector-missing cluster still gets the rest.
|
|
||||||
let fts_ddl = r#"
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_text_search
|
|
||||||
ON ene.chat_messages USING GIN(to_tsvector('english', COALESCE(text_content, '')));
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_tool_search
|
|
||||||
ON ene.chat_messages USING GIN(tool_calls jsonb_path_ops);
|
|
||||||
"#;
|
|
||||||
self.client.batch_execute(ddl).await.context("init DDL")?;
|
|
||||||
if let Err(e) = self.client.batch_execute(fts_ddl).await {
|
|
||||||
warn!("FTS index creation skipped (non-fatal): {}", e);
|
|
||||||
}
|
|
||||||
info!("RDS schema initialized");
|
info!("RDS schema initialized");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -159,32 +80,32 @@ CREATE INDEX IF NOT EXISTS idx_chat_messages_tool_search
|
||||||
let meta_json = serde_json::to_value(&s.meta)?;
|
let meta_json = serde_json::to_value(&s.meta)?;
|
||||||
self.client
|
self.client
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO ene.chat_sessions \
|
"INSERT INTO ene.chat_sessions \\
|
||||||
(session_id, title, agent, model, \
|
(session_id, title, agent, model, \\
|
||||||
workspace_fingerprint, workspace_root, fork_parent_session_id, \
|
workspace_fingerprint, workspace_root, fork_parent_session_id, \\
|
||||||
compaction_count, compaction_summary, message_count, \
|
compaction_count, compaction_summary, message_count, \\
|
||||||
token_input_total, token_output_total, \
|
token_input_total, token_output_total, \\
|
||||||
created_at_ms, updated_at_ms, first_message_at_ms, last_message_at_ms, \
|
created_at_ms, updated_at_ms, first_message_at_ms, last_message_at_ms, \\
|
||||||
embedding, meta, receipt, updated_at) \
|
embedding, meta, receipt, updated_at) \\
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17::vector,$18,$19,now()) \
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17::vector,$18,$19,now()) \\
|
||||||
ON CONFLICT (session_id) DO UPDATE SET \
|
ON CONFLICT (session_id) DO UPDATE SET \\
|
||||||
title = EXCLUDED.title, \
|
title = EXCLUDED.title, \\
|
||||||
agent = EXCLUDED.agent, \
|
agent = EXCLUDED.agent, \\
|
||||||
model = EXCLUDED.model, \
|
model = EXCLUDED.model, \\
|
||||||
workspace_fingerprint = EXCLUDED.workspace_fingerprint, \
|
workspace_fingerprint = EXCLUDED.workspace_fingerprint, \\
|
||||||
workspace_root = EXCLUDED.workspace_root, \
|
workspace_root = EXCLUDED.workspace_root, \\
|
||||||
fork_parent_session_id = EXCLUDED.fork_parent_session_id, \
|
fork_parent_session_id = EXCLUDED.fork_parent_session_id, \\
|
||||||
compaction_count = EXCLUDED.compaction_count, \
|
compaction_count = EXCLUDED.compaction_count, \\
|
||||||
compaction_summary = EXCLUDED.compaction_summary, \
|
compaction_summary = EXCLUDED.compaction_summary, \\
|
||||||
message_count = EXCLUDED.message_count, \
|
message_count = EXCLUDED.message_count, \\
|
||||||
token_input_total = EXCLUDED.token_input_total, \
|
token_input_total = EXCLUDED.token_input_total, \\
|
||||||
token_output_total = EXCLUDED.token_output_total, \
|
token_output_total = EXCLUDED.token_output_total, \\
|
||||||
updated_at_ms = EXCLUDED.updated_at_ms, \
|
updated_at_ms = EXCLUDED.updated_at_ms, \\
|
||||||
first_message_at_ms = EXCLUDED.first_message_at_ms, \
|
first_message_at_ms = EXCLUDED.first_message_at_ms, \\
|
||||||
last_message_at_ms = EXCLUDED.last_message_at_ms, \
|
last_message_at_ms = EXCLUDED.last_message_at_ms, \\
|
||||||
embedding = EXCLUDED.embedding, \
|
embedding = EXCLUDED.embedding, \\
|
||||||
meta = EXCLUDED.meta, \
|
meta = EXCLUDED.meta, \\
|
||||||
receipt = EXCLUDED.receipt, \
|
receipt = EXCLUDED.receipt, \\
|
||||||
updated_at = now()",
|
updated_at = now()",
|
||||||
&[
|
&[
|
||||||
&s.session_id,
|
&s.session_id,
|
||||||
|
|
@ -230,22 +151,22 @@ CREATE INDEX IF NOT EXISTS idx_chat_messages_tool_search
|
||||||
let tool_calls_json = serde_json::to_value(&msg.tool_calls)?;
|
let tool_calls_json = serde_json::to_value(&msg.tool_calls)?;
|
||||||
self.client
|
self.client
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO ene.chat_messages \
|
"INSERT INTO ene.chat_messages \\
|
||||||
(session_id, message_index, role, blocks, text_content, \
|
(session_id, message_index, role, blocks, text_content, \\
|
||||||
token_input, token_output, token_cache_creation, token_cache_read, \
|
token_input, token_output, token_cache_creation, token_cache_read, \\
|
||||||
tool_calls, embedding, receipt_hash, created_at_ms, created_at) \
|
tool_calls, embedding, receipt_hash, created_at_ms, created_at) \\
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::vector,$12,$13,now()) \
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::vector,$12,$13,now()) \\
|
||||||
ON CONFLICT (session_id, message_index) DO UPDATE SET \
|
ON CONFLICT (session_id, message_index) DO UPDATE SET \\
|
||||||
role = EXCLUDED.role, \
|
role = EXCLUDED.role, \\
|
||||||
blocks = EXCLUDED.blocks, \
|
blocks = EXCLUDED.blocks, \\
|
||||||
text_content = EXCLUDED.text_content, \
|
text_content = EXCLUDED.text_content, \\
|
||||||
token_input = EXCLUDED.token_input, \
|
token_input = EXCLUDED.token_input, \\
|
||||||
token_output = EXCLUDED.token_output, \
|
token_output = EXCLUDED.token_output, \\
|
||||||
token_cache_creation = EXCLUDED.token_cache_creation, \
|
token_cache_creation = EXCLUDED.token_cache_creation, \\
|
||||||
token_cache_read = EXCLUDED.token_cache_read, \
|
token_cache_read = EXCLUDED.token_cache_read, \\
|
||||||
tool_calls = EXCLUDED.tool_calls, \
|
tool_calls = EXCLUDED.tool_calls, \\
|
||||||
embedding = EXCLUDED.embedding, \
|
embedding = EXCLUDED.embedding, \\
|
||||||
receipt_hash = EXCLUDED.receipt_hash, \
|
receipt_hash = EXCLUDED.receipt_hash, \\
|
||||||
created_at_ms = EXCLUDED.created_at_ms",
|
created_at_ms = EXCLUDED.created_at_ms",
|
||||||
&[
|
&[
|
||||||
&session_id,
|
&session_id,
|
||||||
|
|
@ -297,8 +218,8 @@ CREATE INDEX IF NOT EXISTS idx_chat_messages_tool_search
|
||||||
let meta_json = serde_json::to_value(&r.meta)?;
|
let meta_json = serde_json::to_value(&r.meta)?;
|
||||||
self.client
|
self.client
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO ene.ingestion_receipts \
|
"INSERT INTO ene.ingestion_receipts \\
|
||||||
(shim_name, status, sha256, record_count, source_path, meta) \
|
(shim_name, status, sha256, record_count, source_path, meta) \\
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
&[
|
&[
|
||||||
&r.shim_name,
|
&r.shim_name,
|
||||||
|
|
@ -323,16 +244,16 @@ CREATE INDEX IF NOT EXISTS idx_chat_messages_tool_search
|
||||||
let rows = self
|
let rows = self
|
||||||
.client
|
.client
|
||||||
.query(
|
.query(
|
||||||
"SELECT s.session_id, s.title, s.agent, s.model, \
|
"SELECT s.session_id, s.title, s.agent, s.model, \\
|
||||||
COUNT(m.id) AS match_count, \
|
COUNT(m.id) AS match_count, \\
|
||||||
MAX(ts_rank(to_tsvector('english', COALESCE(m.text_content,'')), \
|
MAX(ts_rank(to_tsvector('english', COALESCE(m.text_content,'')), \\
|
||||||
plainto_tsquery('english', $1))) AS rank \
|
plainto_tsquery('english', $1))) AS rank \\
|
||||||
FROM ene.chat_sessions s \
|
FROM ene.chat_sessions s \\
|
||||||
JOIN ene.chat_messages m ON m.session_id = s.session_id \
|
JOIN ene.chat_messages m ON m.session_id = s.session_id \\
|
||||||
WHERE to_tsvector('english', COALESCE(m.text_content,'')) \
|
WHERE to_tsvector('english', COALESCE(m.text_content,'')) \\
|
||||||
@@ plainto_tsquery('english', $1) \
|
@@ plainto_tsquery('english', $1) \\
|
||||||
GROUP BY s.session_id, s.title, s.agent, s.model \
|
GROUP BY s.session_id, s.title, s.agent, s.model \\
|
||||||
ORDER BY rank DESC \
|
ORDER BY rank DESC \\
|
||||||
LIMIT $2",
|
LIMIT $2",
|
||||||
&[&query, &limit],
|
&[&query, &limit],
|
||||||
)
|
)
|
||||||
|
|
@ -369,11 +290,11 @@ CREATE INDEX IF NOT EXISTS idx_chat_messages_tool_search
|
||||||
let rows = self
|
let rows = self
|
||||||
.client
|
.client
|
||||||
.query(
|
.query(
|
||||||
"SELECT session_id, title, agent, model, \
|
"SELECT session_id, title, agent, model, \\
|
||||||
1 - (embedding <=> $1::vector) AS similarity \
|
1 - (embedding <=> $1::vector) AS similarity \\
|
||||||
FROM ene.chat_sessions \
|
FROM ene.chat_sessions \\
|
||||||
WHERE embedding IS NOT NULL \
|
WHERE embedding IS NOT NULL \\
|
||||||
ORDER BY embedding <=> $1::vector \
|
ORDER BY embedding <=> $1::vector \\
|
||||||
LIMIT $2",
|
LIMIT $2",
|
||||||
&[&vec_str, &limit],
|
&[&vec_str, &limit],
|
||||||
)
|
)
|
||||||
|
|
@ -397,10 +318,10 @@ CREATE INDEX IF NOT EXISTS idx_chat_messages_tool_search
|
||||||
let rows = self
|
let rows = self
|
||||||
.client
|
.client
|
||||||
.query(
|
.query(
|
||||||
"SELECT session_id, title, agent, model, message_count, \
|
"SELECT session_id, title, agent, model, message_count, \\
|
||||||
token_input_total, token_output_total, created_at_ms, updated_at_ms \
|
token_input_total, token_output_total, created_at_ms, updated_at_ms \\
|
||||||
FROM ene.chat_sessions \
|
FROM ene.chat_sessions \\
|
||||||
ORDER BY updated_at_ms DESC \
|
ORDER BY updated_at_ms DESC \\
|
||||||
LIMIT $1",
|
LIMIT $1",
|
||||||
&[&limit],
|
&[&limit],
|
||||||
)
|
)
|
||||||
|
|
@ -428,8 +349,8 @@ CREATE INDEX IF NOT EXISTS idx_chat_messages_tool_search
|
||||||
let Some(sess) = self
|
let Some(sess) = self
|
||||||
.client
|
.client
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT session_id, title, agent, model, message_count, \
|
"SELECT session_id, title, agent, model, message_count, \\
|
||||||
token_input_total, token_output_total, created_at_ms, updated_at_ms, meta \
|
token_input_total, token_output_total, created_at_ms, updated_at_ms, meta \\
|
||||||
FROM ene.chat_sessions WHERE session_id = $1",
|
FROM ene.chat_sessions WHERE session_id = $1",
|
||||||
&[&session_id],
|
&[&session_id],
|
||||||
)
|
)
|
||||||
|
|
@ -442,9 +363,9 @@ CREATE INDEX IF NOT EXISTS idx_chat_messages_tool_search
|
||||||
let msg_rows = self
|
let msg_rows = self
|
||||||
.client
|
.client
|
||||||
.query(
|
.query(
|
||||||
"SELECT message_index, role, blocks, text_content, \
|
"SELECT message_index, role, blocks, text_content, \\
|
||||||
token_input, token_output, tool_calls, created_at_ms \
|
token_input, token_output, tool_calls, created_at_ms \\
|
||||||
FROM ene.chat_messages \
|
FROM ene.chat_messages \\
|
||||||
WHERE session_id = $1 ORDER BY message_index",
|
WHERE session_id = $1 ORDER BY message_index",
|
||||||
&[&session_id],
|
&[&session_id],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,28 @@ pub const MAX_TITLE_LEN: usize = 160;
|
||||||
/// Maximum allowed content size in bytes (256 KB).
|
/// Maximum allowed content size in bytes (256 KB).
|
||||||
pub const MAX_CONTENT_BYTES: usize = 262_144;
|
pub const MAX_CONTENT_BYTES: usize = 262_144;
|
||||||
|
|
||||||
|
// ── Node/provenance defaults (configurable) ────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ProvenanceDefaults {
|
||||||
|
node: String,
|
||||||
|
lake_seed: String,
|
||||||
|
tailscale_ip: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provenance_defaults() -> ProvenanceDefaults {
|
||||||
|
// These defaults are intentionally conservative; callers should set env vars
|
||||||
|
// in production.
|
||||||
|
let node = std::env::var("ENE_NODE_ID").unwrap_or_else(|_| "ene".to_string());
|
||||||
|
let lake_seed = std::env::var("ENE_LAKE_SEED").unwrap_or_else(|_| "local".to_string());
|
||||||
|
let tailscale_ip = std::env::var("ENE_TAILSCALE_IP").unwrap_or_else(|_| "".to_string());
|
||||||
|
ProvenanceDefaults {
|
||||||
|
node,
|
||||||
|
lake_seed,
|
||||||
|
tailscale_ip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Structs ────────────────────────────────────────────────────────────────────
|
// ── Structs ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// A wiki page header (latest-revision summary, no body text).
|
/// A wiki page header (latest-revision summary, no body text).
|
||||||
|
|
@ -49,10 +71,7 @@ pub struct WikiRevision {
|
||||||
|
|
||||||
/// Trim and collapse whitespace in a wiki title; reject empty or oversized titles.
|
/// Trim and collapse whitespace in a wiki title; reject empty or oversized titles.
|
||||||
pub fn normalize_title(title: &str) -> Result<String> {
|
pub fn normalize_title(title: &str) -> Result<String> {
|
||||||
let cleaned: String = title
|
let cleaned: String = title.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
.split_whitespace()
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ");
|
|
||||||
if cleaned.is_empty() {
|
if cleaned.is_empty() {
|
||||||
return Err(anyhow!("wiki title is required"));
|
return Err(anyhow!("wiki title is required"));
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +145,6 @@ pub fn canonical_json(v: &Value) -> String {
|
||||||
fn to_sorted(v: &Value) -> Value {
|
fn to_sorted(v: &Value) -> Value {
|
||||||
match v {
|
match v {
|
||||||
Value::Object(map) => {
|
Value::Object(map) => {
|
||||||
// Collect into a BTreeMap so keys are sorted lexicographically.
|
|
||||||
let sorted: std::collections::BTreeMap<_, _> = map
|
let sorted: std::collections::BTreeMap<_, _> = map
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, vv)| (k.clone(), to_sorted(vv)))
|
.map(|(k, vv)| (k.clone(), to_sorted(vv)))
|
||||||
|
|
@ -145,7 +163,6 @@ pub fn canonical_json(v: &Value) -> String {
|
||||||
serde_json::to_string(&sorted).unwrap_or_else(|_| "null".to_string())
|
serde_json::to_string(&sorted).unwrap_or_else(|_| "null".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manual `[[...]]` span parser — returns `(inner_text, start_byte)` for each span.
|
|
||||||
fn wiki_spans(text: &str) -> Vec<&str> {
|
fn wiki_spans(text: &str) -> Vec<&str> {
|
||||||
let bytes = text.as_bytes();
|
let bytes = text.as_bytes();
|
||||||
let len = bytes.len();
|
let len = bytes.len();
|
||||||
|
|
@ -153,12 +170,10 @@ fn wiki_spans(text: &str) -> Vec<&str> {
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i + 1 < len {
|
while i + 1 < len {
|
||||||
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
|
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
|
||||||
// Find the matching `]]`.
|
|
||||||
let start = i + 2;
|
let start = i + 2;
|
||||||
let mut j = start;
|
let mut j = start;
|
||||||
while j + 1 < len {
|
while j + 1 < len {
|
||||||
if bytes[j] == b']' && bytes[j + 1] == b']' {
|
if bytes[j] == b']' && bytes[j + 1] == b']' {
|
||||||
// Reject spans that contain newlines or nested brackets.
|
|
||||||
let inner = &text[start..j];
|
let inner = &text[start..j];
|
||||||
if !inner.contains('\n') && !inner.contains('[') && !inner.contains(']') {
|
if !inner.contains('\n') && !inner.contains('[') && !inner.contains(']') {
|
||||||
spans.push(inner);
|
spans.push(inner);
|
||||||
|
|
@ -169,7 +184,6 @@ fn wiki_spans(text: &str) -> Vec<&str> {
|
||||||
j += 1;
|
j += 1;
|
||||||
}
|
}
|
||||||
if j + 1 >= len {
|
if j + 1 >= len {
|
||||||
// Unclosed span — advance past the opening `[[`.
|
|
||||||
i += 2;
|
i += 2;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -179,20 +193,11 @@ fn wiki_spans(text: &str) -> Vec<&str> {
|
||||||
spans
|
spans
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the link target from a raw `[[...]]` inner string.
|
|
||||||
///
|
|
||||||
/// Strip everything from the first `|` or `#` onward.
|
|
||||||
fn link_target(inner: &str) -> &str {
|
fn link_target(inner: &str) -> &str {
|
||||||
let cut = inner
|
let cut = inner.find(|c| c == '|' || c == '#').unwrap_or(inner.len());
|
||||||
.find(|c| c == '|' || c == '#')
|
|
||||||
.unwrap_or(inner.len());
|
|
||||||
&inner[..cut]
|
&inner[..cut]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract all wiki links from `text`.
|
|
||||||
///
|
|
||||||
/// Returns deduplicated, case-insensitively sorted normalized titles.
|
|
||||||
/// Skips spans whose target (after normalization) starts with `category:`.
|
|
||||||
pub fn extract_links(text: &str) -> Vec<String> {
|
pub fn extract_links(text: &str) -> Vec<String> {
|
||||||
let mut links: Vec<String> = Vec::new();
|
let mut links: Vec<String> = Vec::new();
|
||||||
for inner in wiki_spans(text) {
|
for inner in wiki_spans(text) {
|
||||||
|
|
@ -206,26 +211,18 @@ pub fn extract_links(text: &str) -> Vec<String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Deduplicate.
|
|
||||||
links.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
|
links.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
|
||||||
links.dedup_by(|a, b| a.to_lowercase() == b.to_lowercase());
|
links.dedup_by(|a, b| a.to_lowercase() == b.to_lowercase());
|
||||||
links
|
links
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract all `[[Category:...]]` entries from `text`.
|
|
||||||
///
|
|
||||||
/// Returns deduplicated, case-insensitively sorted category names (prefix stripped).
|
|
||||||
pub fn extract_categories(text: &str) -> Vec<String> {
|
pub fn extract_categories(text: &str) -> Vec<String> {
|
||||||
let mut cats: Vec<String> = Vec::new();
|
let mut cats: Vec<String> = Vec::new();
|
||||||
for inner in wiki_spans(text) {
|
for inner in wiki_spans(text) {
|
||||||
let target = link_target(inner).trim();
|
let target = link_target(inner).trim();
|
||||||
// Accept `Category:Foo` or `category:foo` (case-insensitive prefix).
|
|
||||||
let lower = target.to_lowercase();
|
let lower = target.to_lowercase();
|
||||||
// The Python CATEGORY_RE also allows whitespace: `Category : Foo`.
|
|
||||||
// We replicate that by checking the prefix after collapsing whitespace.
|
|
||||||
let condensed: String = target.split_whitespace().collect::<Vec<_>>().join(" ");
|
let condensed: String = target.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
let cond_lower = condensed.to_lowercase();
|
let cond_lower = condensed.to_lowercase();
|
||||||
// Match `category :` or `category:` prefix.
|
|
||||||
let remainder = if cond_lower.starts_with("category :") {
|
let remainder = if cond_lower.starts_with("category :") {
|
||||||
Some(condensed["category :".len()..].trim().to_string())
|
Some(condensed["category :".len()..].trim().to_string())
|
||||||
} else if lower.starts_with("category:") {
|
} else if lower.starts_with("category:") {
|
||||||
|
|
@ -244,10 +241,6 @@ pub fn extract_categories(text: &str) -> Vec<String> {
|
||||||
cats
|
cats
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the deterministic write receipt for a revision.
|
|
||||||
///
|
|
||||||
/// Receipt = SHA-256 hex of canonical JSON of
|
|
||||||
/// `{"author":…,"created_at":…,"revision":…,"slug":…,"text_sha256":…}`.
|
|
||||||
pub fn write_receipt(
|
pub fn write_receipt(
|
||||||
slug: &str,
|
slug: &str,
|
||||||
revision: i64,
|
revision: i64,
|
||||||
|
|
@ -256,7 +249,6 @@ pub fn write_receipt(
|
||||||
created_at_ms: i64,
|
created_at_ms: i64,
|
||||||
) -> String {
|
) -> String {
|
||||||
let text_sha256 = sha256_hex(text.as_bytes());
|
let text_sha256 = sha256_hex(text.as_bytes());
|
||||||
// Build a BTreeMap so keys are sorted identically to Python's sort_keys=True.
|
|
||||||
let mut map = std::collections::BTreeMap::new();
|
let mut map = std::collections::BTreeMap::new();
|
||||||
map.insert("author", serde_json::Value::String(author.to_string()));
|
map.insert("author", serde_json::Value::String(author.to_string()));
|
||||||
map.insert(
|
map.insert(
|
||||||
|
|
@ -273,7 +265,6 @@ pub fn write_receipt(
|
||||||
sha256_hex(payload.as_bytes())
|
sha256_hex(payload.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count occurrences of `needle` (as a plain substring) in `haystack`.
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn count_substr(haystack: &str, needle: &str) -> usize {
|
fn count_substr(haystack: &str, needle: &str) -> usize {
|
||||||
if needle.is_empty() {
|
if needle.is_empty() {
|
||||||
|
|
@ -288,9 +279,6 @@ fn count_substr(haystack: &str, needle: &str) -> usize {
|
||||||
count
|
count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count distinct "word-like" tokens matching `[a-zA-Z][a-zA-Z0-9_]{2,}`.
|
|
||||||
///
|
|
||||||
/// This replicates `len(set(re.findall(r"[a-zA-Z][a-zA-Z0-9_]{2,}", lowered)))`.
|
|
||||||
fn distinct_word_tokens(text: &str) -> usize {
|
fn distinct_word_tokens(text: &str) -> usize {
|
||||||
let bytes = text.as_bytes();
|
let bytes = text.as_bytes();
|
||||||
let len = bytes.len();
|
let len = bytes.len();
|
||||||
|
|
@ -298,7 +286,6 @@ fn distinct_word_tokens(text: &str) -> usize {
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < len {
|
while i < len {
|
||||||
let b = bytes[i];
|
let b = bytes[i];
|
||||||
// Must start with a letter (a-z after lowering).
|
|
||||||
if b.is_ascii_alphabetic() {
|
if b.is_ascii_alphabetic() {
|
||||||
let start = i;
|
let start = i;
|
||||||
i += 1;
|
i += 1;
|
||||||
|
|
@ -306,7 +293,6 @@ fn distinct_word_tokens(text: &str) -> usize {
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
let word = &text[start..i];
|
let word = &text[start..i];
|
||||||
// Must be at least 3 characters (1 leading + at least 2 more).
|
|
||||||
if word.len() >= 3 {
|
if word.len() >= 3 {
|
||||||
tokens.insert(word.to_string());
|
tokens.insert(word.to_string());
|
||||||
}
|
}
|
||||||
|
|
@ -317,24 +303,7 @@ fn distinct_word_tokens(text: &str) -> usize {
|
||||||
tokens.len()
|
tokens.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive a deterministic 14-axis concept vector for a wiki page.
|
pub fn concept_vector_for_wiki(title: &str, text: &str, links: &[String], categories: &[String]) -> Vec<f64> {
|
||||||
///
|
|
||||||
/// Axes 0,1,3,4,8,9,10 are always 0.0. Active axes:
|
|
||||||
/// - axis 2 : topology / manifold / links
|
|
||||||
/// - axis 5 : hash / receipt / verify
|
|
||||||
/// - axis 6 : sqlite / schema / index
|
|
||||||
/// - axis 7 : lexical richness (distinct tokens / 500)
|
|
||||||
/// - axis 11 : proof / lean / theorem
|
|
||||||
/// - axis 12 : categories / archive / history
|
|
||||||
/// - axis 13 : author / provenance / attest
|
|
||||||
///
|
|
||||||
/// The vector is L2-normalised then rounded to 6 decimal places.
|
|
||||||
pub fn concept_vector_for_wiki(
|
|
||||||
title: &str,
|
|
||||||
text: &str,
|
|
||||||
links: &[String],
|
|
||||||
categories: &[String],
|
|
||||||
) -> Vec<f64> {
|
|
||||||
let combined = format!("{}\n{}", title, text).to_lowercase();
|
let combined = format!("{}\n{}", title, text).to_lowercase();
|
||||||
let mut axes = vec![0.0f64; 14];
|
let mut axes = vec![0.0f64; 14];
|
||||||
|
|
||||||
|
|
@ -382,31 +351,18 @@ pub fn concept_vector_for_wiki(
|
||||||
/ 8.0,
|
/ 8.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// If all axes are zero, set the lexical richness axis to 1.
|
|
||||||
if axes.iter().all(|&x| x == 0.0) {
|
if axes.iter().all(|&x| x == 0.0) {
|
||||||
axes[7] = 1.0;
|
axes[7] = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// L2 normalise.
|
|
||||||
let norm = axes.iter().map(|x| x * x).sum::<f64>().sqrt();
|
let norm = axes.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||||||
axes.iter()
|
axes.iter()
|
||||||
.map(|&x| {
|
.map(|&x| if norm > 0.0 { (x / norm * 1_000_000.0).round() / 1_000_000.0 } else { 0.0 })
|
||||||
if norm > 0.0 {
|
|
||||||
// Round to 6 decimal places.
|
|
||||||
(x / norm * 1_000_000.0).round() / 1_000_000.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bin a concept vector into 0–7 genome integers and return the six named axes.
|
|
||||||
pub fn genome_from_vector(v: &[f64]) -> Value {
|
pub fn genome_from_vector(v: &[f64]) -> Value {
|
||||||
let bins: Vec<u8> = v
|
let bins: Vec<u8> = v.iter().map(|&x| u8::min(7, (x * 8.0) as u8)).collect();
|
||||||
.iter()
|
|
||||||
.map(|&x| u8::min(7, (x * 8.0) as u8))
|
|
||||||
.collect();
|
|
||||||
let get = |idx: usize| -> i64 { bins.get(idx).copied().unwrap_or(0) as i64 };
|
let get = |idx: usize| -> i64 { bins.get(idx).copied().unwrap_or(0) as i64 };
|
||||||
json!({
|
json!({
|
||||||
"mu": get(1),
|
"mu": get(1),
|
||||||
|
|
@ -418,16 +374,10 @@ pub fn genome_from_vector(v: &[f64]) -> Value {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the archive_id string for a slug + content_hash pair.
|
|
||||||
fn archive_id_for(slug: &str, content_hash: &str) -> String {
|
fn archive_id_for(slug: &str, content_hash: &str) -> String {
|
||||||
format!(
|
format!("json_catalog_ene_wiki_{}_{}", slug, &content_hash[..content_hash.len().min(16)])
|
||||||
"json_catalog_ene_wiki_{}_{}",
|
|
||||||
slug,
|
|
||||||
&content_hash[..content_hash.len().min(16)]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the full archive record dict matching the Python `make_archive_record()`.
|
|
||||||
pub fn make_archive_record(
|
pub fn make_archive_record(
|
||||||
title: &str,
|
title: &str,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
|
|
@ -466,20 +416,15 @@ pub fn make_archive_record(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the JSONL event dict matching the Python `make_jsonl_event()`.
|
|
||||||
pub fn make_jsonl_event(record: &Value, concept_vector: &[f64], receipt: &str) -> Value {
|
pub fn make_jsonl_event(record: &Value, concept_vector: &[f64], receipt: &str) -> Value {
|
||||||
|
let defaults = provenance_defaults();
|
||||||
|
|
||||||
let now_ms = now_unix_ms() as f64 / 1_000.0;
|
let now_ms = now_unix_ms() as f64 / 1_000.0;
|
||||||
let raw = &record["raw_content"];
|
let raw = &record["raw_content"];
|
||||||
let slug = raw["slug"].as_str().unwrap_or("");
|
let slug = raw["slug"].as_str().unwrap_or("");
|
||||||
let title = raw["title"].as_str().unwrap_or("");
|
let title = raw["title"].as_str().unwrap_or("");
|
||||||
let links_len = raw["links"]
|
let links_len = raw["links"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||||
.as_array()
|
let categories_arr: Vec<Value> = raw["categories"].as_array().cloned().unwrap_or_default();
|
||||||
.map(|a| a.len())
|
|
||||||
.unwrap_or(0);
|
|
||||||
let categories_arr: Vec<Value> = raw["categories"]
|
|
||||||
.as_array()
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let content_hash = record["content_hash"].as_str().unwrap_or("");
|
let content_hash = record["content_hash"].as_str().unwrap_or("");
|
||||||
let extracted_at = record["extracted_at"].as_str().unwrap_or("");
|
let extracted_at = record["extracted_at"].as_str().unwrap_or("");
|
||||||
let archive_id = record["archive_id"].as_str().unwrap_or("");
|
let archive_id = record["archive_id"].as_str().unwrap_or("");
|
||||||
|
|
@ -488,14 +433,10 @@ pub fn make_jsonl_event(record: &Value, concept_vector: &[f64], receipt: &str) -
|
||||||
let pkg = format!("ene/wiki/{}", slug);
|
let pkg = format!("ene/wiki/{}", slug);
|
||||||
let event_id = format!("ene:{}", archive_id);
|
let event_id = format!("ene:{}", archive_id);
|
||||||
|
|
||||||
// bind.cost = max(1, len(extracted_text.encode("utf-8"))) << 16
|
|
||||||
let text_bytes = extracted_text.len().max(1);
|
let text_bytes = extracted_text.len().max(1);
|
||||||
let cost: i64 = (text_bytes as i64) << 16;
|
let cost: i64 = (text_bytes as i64) << 16;
|
||||||
|
|
||||||
let mut tags = vec![
|
let mut tags = vec![Value::String("ene".to_string()), Value::String("wiki".to_string())];
|
||||||
Value::String("ene".to_string()),
|
|
||||||
Value::String("wiki".to_string()),
|
|
||||||
];
|
|
||||||
for cat in &categories_arr {
|
for cat in &categories_arr {
|
||||||
tags.push(cat.clone());
|
tags.push(cat.clone());
|
||||||
}
|
}
|
||||||
|
|
@ -540,18 +481,15 @@ pub fn make_jsonl_event(record: &Value, concept_vector: &[f64], receipt: &str) -
|
||||||
"class": "informational_bind",
|
"class": "informational_bind",
|
||||||
},
|
},
|
||||||
"provenance": {
|
"provenance": {
|
||||||
"node": "ene-wiki-layer",
|
"node": defaults.node,
|
||||||
"lake_seed": "local",
|
"lake_seed": defaults.lake_seed,
|
||||||
"tailscale_ip": "",
|
"tailscale_ip": defaults.tailscale_ip,
|
||||||
"attestation_hash": format!("sha256:{}", receipt),
|
"attestation_hash": format!("sha256:{}", receipt),
|
||||||
"prev_id": Value::Null,
|
"prev_id": Value::Null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return `true` if the text passes the content admission gate.
|
|
||||||
///
|
|
||||||
/// Rejects: oversized text, `<script` tags, `javascript:` URLs.
|
|
||||||
pub fn admit_write(text: &str) -> bool {
|
pub fn admit_write(text: &str) -> bool {
|
||||||
if text.len() >= MAX_CONTENT_BYTES {
|
if text.len() >= MAX_CONTENT_BYTES {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -565,7 +503,6 @@ pub fn admit_write(text: &str) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current Unix time as milliseconds.
|
|
||||||
fn now_unix_ms() -> i64 {
|
fn now_unix_ms() -> i64 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
|
|
@ -575,13 +512,11 @@ fn now_unix_ms() -> i64 {
|
||||||
|
|
||||||
// ── ENEWikiLayer (SQLite) ──────────────────────────────────────────────────────
|
// ── ENEWikiLayer (SQLite) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// SQLite-backed wiki layer. Drop-in equivalent of `ene_wiki_layer.ENEWikiLayer`.
|
|
||||||
pub struct ENEWikiLayer {
|
pub struct ENEWikiLayer {
|
||||||
pub db_path: PathBuf,
|
pub db_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ENEWikiLayer {
|
impl ENEWikiLayer {
|
||||||
/// Open (or create) a wiki backed by the SQLite database at `db_path`.
|
|
||||||
pub fn new(db_path: impl AsRef<Path>) -> Result<Self> {
|
pub fn new(db_path: impl AsRef<Path>) -> Result<Self> {
|
||||||
let db_path = db_path.as_ref().to_path_buf();
|
let db_path = db_path.as_ref().to_path_buf();
|
||||||
if let Some(parent) = db_path.parent() {
|
if let Some(parent) = db_path.parent() {
|
||||||
|
|
@ -593,7 +528,6 @@ impl ENEWikiLayer {
|
||||||
Ok(layer)
|
Ok(layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a raw SQLite connection to the backing database.
|
|
||||||
fn open(&self) -> Result<rusqlite::Connection> {
|
fn open(&self) -> Result<rusqlite::Connection> {
|
||||||
let conn = rusqlite::Connection::open(&self.db_path)
|
let conn = rusqlite::Connection::open(&self.db_path)
|
||||||
.with_context(|| format!("opening sqlite db {:?}", self.db_path))?;
|
.with_context(|| format!("opening sqlite db {:?}", self.db_path))?;
|
||||||
|
|
@ -601,7 +535,6 @@ impl ENEWikiLayer {
|
||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create all tables if they do not exist.
|
|
||||||
pub fn init_tables(&self) -> Result<()> {
|
pub fn init_tables(&self) -> Result<()> {
|
||||||
let conn = self.open()?;
|
let conn = self.open()?;
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
|
|
@ -663,7 +596,6 @@ impl ENEWikiLayer {
|
||||||
)
|
)
|
||||||
.context("creating wiki tables")?;
|
.context("creating wiki tables")?;
|
||||||
|
|
||||||
// Ensure optional columns exist on ene_wiki_revisions (schema migration).
|
|
||||||
let extra_cols = [
|
let extra_cols = [
|
||||||
("archive_id", "TEXT"),
|
("archive_id", "TEXT"),
|
||||||
("content_hash", "TEXT"),
|
("content_hash", "TEXT"),
|
||||||
|
|
@ -671,25 +603,21 @@ impl ENEWikiLayer {
|
||||||
("jsonl_event", "TEXT"),
|
("jsonl_event", "TEXT"),
|
||||||
];
|
];
|
||||||
let existing: Vec<String> = {
|
let existing: Vec<String> = {
|
||||||
let mut stmt = conn
|
let mut stmt = conn.prepare("PRAGMA table_info(ene_wiki_revisions)")?;
|
||||||
.prepare("PRAGMA table_info(ene_wiki_revisions)")?;
|
let cols: Vec<String> = stmt
|
||||||
let cols: Vec<String> = stmt.query_map([], |row| row.get::<_, String>(1))?
|
.query_map([], |row| row.get::<_, String>(1))?
|
||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
.collect();
|
.collect();
|
||||||
cols
|
cols
|
||||||
};
|
};
|
||||||
for (col, decl) in &extra_cols {
|
for (col, decl) in &extra_cols {
|
||||||
if !existing.contains(&col.to_string()) {
|
if !existing.contains(&col.to_string()) {
|
||||||
conn.execute_batch(&format!(
|
conn.execute_batch(&format!("ALTER TABLE ene_wiki_revisions ADD COLUMN {} {}", col, decl))?;
|
||||||
"ALTER TABLE ene_wiki_revisions ADD COLUMN {} {}",
|
|
||||||
col, decl
|
|
||||||
))?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upsert a packages row from a JSONL event.
|
|
||||||
fn upsert_package(conn: &rusqlite::Connection, event: &Value) -> Result<()> {
|
fn upsert_package(conn: &rusqlite::Connection, event: &Value) -> Result<()> {
|
||||||
let data = &event["data"];
|
let data = &event["data"];
|
||||||
let pkg = data["pkg"].as_str().unwrap_or("");
|
let pkg = data["pkg"].as_str().unwrap_or("");
|
||||||
|
|
@ -739,14 +667,7 @@ impl ENEWikiLayer {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write or update a wiki page, appending a new revision.
|
pub fn put_page(&self, title: &str, text: &str, author: &str, summary: &str) -> Result<WikiRevision> {
|
||||||
pub fn put_page(
|
|
||||||
&self,
|
|
||||||
title: &str,
|
|
||||||
text: &str,
|
|
||||||
author: &str,
|
|
||||||
summary: &str,
|
|
||||||
) -> Result<WikiRevision> {
|
|
||||||
if !admit_write(text) {
|
if !admit_write(text) {
|
||||||
return Err(anyhow!("wiki write rejected: content failed admission gate"));
|
return Err(anyhow!("wiki write rejected: content failed admission gate"));
|
||||||
}
|
}
|
||||||
|
|
@ -756,7 +677,6 @@ impl ENEWikiLayer {
|
||||||
|
|
||||||
let conn = self.open()?;
|
let conn = self.open()?;
|
||||||
|
|
||||||
// Determine next revision number.
|
|
||||||
let latest: Option<i64> = conn
|
let latest: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT latest_revision FROM ene_wiki_pages WHERE slug = ?1",
|
"SELECT latest_revision FROM ene_wiki_pages WHERE slug = ?1",
|
||||||
|
|
@ -786,11 +706,9 @@ impl ENEWikiLayer {
|
||||||
|
|
||||||
let archive_id = archive_record["archive_id"].as_str().unwrap_or("").to_string();
|
let archive_id = archive_record["archive_id"].as_str().unwrap_or("").to_string();
|
||||||
let content_hash = archive_record["content_hash"].as_str().unwrap_or("").to_string();
|
let content_hash = archive_record["content_hash"].as_str().unwrap_or("").to_string();
|
||||||
let archive_record_json =
|
let archive_record_json = serde_json::to_string(&archive_record).unwrap_or_default();
|
||||||
serde_json::to_string(&archive_record).unwrap_or_default();
|
|
||||||
let jsonl_event_json = serde_json::to_string(&jsonl_event).unwrap_or_default();
|
let jsonl_event_json = serde_json::to_string(&jsonl_event).unwrap_or_default();
|
||||||
|
|
||||||
// Insert revision.
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO ene_wiki_revisions
|
INSERT INTO ene_wiki_revisions
|
||||||
|
|
@ -807,7 +725,6 @@ impl ENEWikiLayer {
|
||||||
)
|
)
|
||||||
.context("inserting wiki revision")?;
|
.context("inserting wiki revision")?;
|
||||||
|
|
||||||
// Upsert page header.
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO ene_wiki_pages (slug, title, latest_revision, updated_at, receipt)
|
INSERT INTO ene_wiki_pages (slug, title, latest_revision, updated_at, receipt)
|
||||||
|
|
@ -822,24 +739,16 @@ impl ENEWikiLayer {
|
||||||
)
|
)
|
||||||
.context("upserting wiki page")?;
|
.context("upserting wiki page")?;
|
||||||
|
|
||||||
// Replace links.
|
conn.execute("DELETE FROM ene_wiki_links WHERE slug = ?1", rusqlite::params![&slug])?;
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM ene_wiki_links WHERE slug = ?1",
|
|
||||||
rusqlite::params![&slug],
|
|
||||||
)?;
|
|
||||||
for link_title in &links {
|
for link_title in &links {
|
||||||
let target_slug = title_slug(link_title).unwrap_or_default();
|
let target_slug = title_slug(link_title).unwrap_or_default();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
r#"
|
r#"INSERT OR IGNORE INTO ene_wiki_links (slug, target_slug, target_title) VALUES (?1,?2,?3)"#,
|
||||||
INSERT OR IGNORE INTO ene_wiki_links (slug, target_slug, target_title)
|
|
||||||
VALUES (?1,?2,?3)
|
|
||||||
"#,
|
|
||||||
rusqlite::params![&slug, &target_slug, link_title],
|
rusqlite::params![&slug, &target_slug, link_title],
|
||||||
)
|
)
|
||||||
.context("inserting wiki link")?;
|
.context("inserting wiki link")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace categories.
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM ene_wiki_categories WHERE slug = ?1",
|
"DELETE FROM ene_wiki_categories WHERE slug = ?1",
|
||||||
rusqlite::params![&slug],
|
rusqlite::params![&slug],
|
||||||
|
|
@ -868,12 +777,10 @@ impl ENEWikiLayer {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve the latest revision of a page by title, or `None` if not found.
|
|
||||||
pub fn get_page(&self, title: &str) -> Result<Option<WikiRevision>> {
|
pub fn get_page(&self, title: &str) -> Result<Option<WikiRevision>> {
|
||||||
let slug = title_slug(title)?;
|
let slug = title_slug(title)?;
|
||||||
let conn = self.open()?;
|
let conn = self.open()?;
|
||||||
|
|
||||||
// Find latest revision number from the pages table.
|
|
||||||
let latest: Option<i64> = conn
|
let latest: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT latest_revision FROM ene_wiki_pages WHERE slug = ?1",
|
"SELECT latest_revision FROM ene_wiki_pages WHERE slug = ?1",
|
||||||
|
|
@ -887,14 +794,9 @@ impl ENEWikiLayer {
|
||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch the revision body.
|
|
||||||
let row: Option<(String, String, i64, String, String, String, i64, String)> = conn
|
let row: Option<(String, String, i64, String, String, String, i64, String)> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
r#"
|
r#"SELECT title, slug, revision, text, author, summary, created_at, receipt FROM ene_wiki_revisions WHERE slug = ?1 AND revision = ?2"#,
|
||||||
SELECT title, slug, revision, text, author, summary, created_at, receipt
|
|
||||||
FROM ene_wiki_revisions
|
|
||||||
WHERE slug = ?1 AND revision = ?2
|
|
||||||
"#,
|
|
||||||
rusqlite::params![&slug, revision],
|
rusqlite::params![&slug, revision],
|
||||||
|r| {
|
|r| {
|
||||||
Ok((
|
Ok((
|
||||||
|
|
@ -912,13 +814,11 @@ impl ENEWikiLayer {
|
||||||
.optional()
|
.optional()
|
||||||
.context("fetching wiki revision")?;
|
.context("fetching wiki revision")?;
|
||||||
|
|
||||||
let (title_db, slug_db, rev, text, author, summary, created_at_ms, receipt_db) =
|
let (title_db, slug_db, rev, text, author, summary, created_at_ms, receipt_db) = match row {
|
||||||
match row {
|
Some(t) => t,
|
||||||
Some(t) => t,
|
None => return Ok(None),
|
||||||
None => return Ok(None),
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch links.
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT target_title FROM ene_wiki_links WHERE slug = ?1 ORDER BY lower(target_title)",
|
"SELECT target_title FROM ene_wiki_links WHERE slug = ?1 ORDER BY lower(target_title)",
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -927,7 +827,6 @@ impl ENEWikiLayer {
|
||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Fetch categories.
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT category FROM ene_wiki_categories WHERE slug = ?1 ORDER BY lower(category)",
|
"SELECT category FROM ene_wiki_categories WHERE slug = ?1 ORDER BY lower(category)",
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -950,19 +849,12 @@ impl ENEWikiLayer {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search pages by title or slug containing `query` (case-insensitive LIKE).
|
|
||||||
pub fn search(&self, query: &str, limit: i64) -> Result<Vec<WikiPage>> {
|
pub fn search(&self, query: &str, limit: i64) -> Result<Vec<WikiPage>> {
|
||||||
let limit = limit.max(1).min(100);
|
let limit = limit.max(1).min(100);
|
||||||
let term = format!("%{}%", query.trim());
|
let term = format!("%{}%", query.trim());
|
||||||
let conn = self.open()?;
|
let conn = self.open()?;
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
r#"
|
r#"SELECT slug, title, latest_revision, updated_at, receipt FROM ene_wiki_pages WHERE title LIKE ?1 OR slug LIKE ?1 ORDER BY updated_at DESC LIMIT ?2"#,
|
||||||
SELECT slug, title, latest_revision, updated_at, receipt
|
|
||||||
FROM ene_wiki_pages
|
|
||||||
WHERE title LIKE ?1 OR slug LIKE ?1
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
LIMIT ?2
|
|
||||||
"#,
|
|
||||||
)?;
|
)?;
|
||||||
let pages: Vec<WikiPage> = stmt
|
let pages: Vec<WikiPage> = stmt
|
||||||
.query_map(rusqlite::params![&term, limit], |r| {
|
.query_map(rusqlite::params![&term, limit], |r| {
|
||||||
|
|
@ -979,18 +871,11 @@ impl ENEWikiLayer {
|
||||||
Ok(pages)
|
Ok(pages)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return slugs of all pages that link *to* `title` via `ene_wiki_links`.
|
|
||||||
pub fn backlinks(&self, title: &str) -> Result<Vec<String>> {
|
pub fn backlinks(&self, title: &str) -> Result<Vec<String>> {
|
||||||
let target_slug = title_slug(title)?;
|
let target_slug = title_slug(title)?;
|
||||||
let conn = self.open()?;
|
let conn = self.open()?;
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
r#"
|
r#"SELECT l.slug FROM ene_wiki_links l JOIN ene_wiki_pages p ON p.slug = l.slug WHERE l.target_slug = ?1 ORDER BY lower(p.title)"#,
|
||||||
SELECT l.slug
|
|
||||||
FROM ene_wiki_links l
|
|
||||||
JOIN ene_wiki_pages p ON p.slug = l.slug
|
|
||||||
WHERE l.target_slug = ?1
|
|
||||||
ORDER BY lower(p.title)
|
|
||||||
"#,
|
|
||||||
)?;
|
)?;
|
||||||
let slugs: Vec<String> = stmt
|
let slugs: Vec<String> = stmt
|
||||||
.query_map(rusqlite::params![&target_slug], |r| r.get(0))?
|
.query_map(rusqlite::params![&target_slug], |r| r.get(0))?
|
||||||
|
|
@ -999,19 +884,11 @@ impl ENEWikiLayer {
|
||||||
Ok(slugs)
|
Ok(slugs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the most recently changed revisions, newest first.
|
|
||||||
pub fn recent_changes(&self, limit: i64) -> Result<Vec<WikiRevision>> {
|
pub fn recent_changes(&self, limit: i64) -> Result<Vec<WikiRevision>> {
|
||||||
let limit = limit.max(1).min(100);
|
let limit = limit.max(1).min(100);
|
||||||
let conn = self.open()?;
|
let conn = self.open()?;
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
r#"
|
r#"SELECT r.slug, r.title, r.revision, r.text, r.author, r.summary, r.created_at, r.receipt FROM ene_wiki_revisions r JOIN ene_wiki_pages p ON p.slug = r.slug AND p.latest_revision = r.revision ORDER BY r.created_at DESC LIMIT ?1"#,
|
||||||
SELECT r.slug, r.title, r.revision, r.text, r.author, r.summary,
|
|
||||||
r.created_at, r.receipt
|
|
||||||
FROM ene_wiki_revisions r
|
|
||||||
JOIN ene_wiki_pages p ON p.slug = r.slug AND p.latest_revision = r.revision
|
|
||||||
ORDER BY r.created_at DESC
|
|
||||||
LIMIT ?1
|
|
||||||
"#,
|
|
||||||
)?;
|
)?;
|
||||||
let revs: Vec<WikiRevision> = stmt
|
let revs: Vec<WikiRevision> = stmt
|
||||||
.query_map(rusqlite::params![limit], |r| {
|
.query_map(rusqlite::params![limit], |r| {
|
||||||
|
|
@ -1034,30 +911,21 @@ impl ENEWikiLayer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── RdsWikiLayer (PostgreSQL / tokio-postgres) ─────────────────────────────────
|
|
||||||
|
|
||||||
/// PostgreSQL-backed wiki layer. Drop-in equivalent of `ene_rds_wiki_layer.ENERDSWikiLayer`.
|
|
||||||
pub struct RdsWikiLayer {
|
pub struct RdsWikiLayer {
|
||||||
pub dsn: String,
|
pub dsn: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RdsWikiLayer {
|
impl RdsWikiLayer {
|
||||||
/// Create an `RdsWikiLayer` using the given DSN string.
|
|
||||||
///
|
|
||||||
/// Does NOT open a persistent connection — each operation creates a short-lived
|
|
||||||
/// connection, matching the Python psycopg2 pattern.
|
|
||||||
pub async fn new(dsn: &str) -> Result<Self> {
|
pub async fn new(dsn: &str) -> Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
dsn: dsn.to_string(),
|
dsn: dsn.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a tokio-postgres connection pair (client + connection driver).
|
|
||||||
async fn connect(&self) -> Result<tokio_postgres::Client> {
|
async fn connect(&self) -> Result<tokio_postgres::Client> {
|
||||||
let (client, connection) = tokio_postgres::connect(&self.dsn, tokio_postgres::NoTls)
|
let (client, connection) = tokio_postgres::connect(&self.dsn, tokio_postgres::NoTls)
|
||||||
.await
|
.await
|
||||||
.context("connecting to PostgreSQL")?;
|
.context("connecting to PostgreSQL")?;
|
||||||
// Drive the connection in a detached task.
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = connection.await {
|
if let Err(e) = connection.await {
|
||||||
tracing::warn!("postgres connection error: {}", e);
|
tracing::warn!("postgres connection error: {}", e);
|
||||||
|
|
@ -1066,7 +934,6 @@ impl RdsWikiLayer {
|
||||||
Ok(client)
|
Ok(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create all tables in the `ene` schema if they do not exist.
|
|
||||||
pub async fn init_tables(&self) -> Result<()> {
|
pub async fn init_tables(&self) -> Result<()> {
|
||||||
let client = self.connect().await?;
|
let client = self.connect().await?;
|
||||||
client
|
client
|
||||||
|
|
@ -1134,14 +1001,7 @@ impl RdsWikiLayer {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write or update a wiki page, appending a new revision.
|
pub async fn put_page(&self, title: &str, text: &str, author: &str, summary: &str) -> Result<WikiRevision> {
|
||||||
pub async fn put_page(
|
|
||||||
&self,
|
|
||||||
title: &str,
|
|
||||||
text: &str,
|
|
||||||
author: &str,
|
|
||||||
summary: &str,
|
|
||||||
) -> Result<WikiRevision> {
|
|
||||||
if !admit_write(text) {
|
if !admit_write(text) {
|
||||||
return Err(anyhow!("wiki write rejected: content failed admission gate"));
|
return Err(anyhow!("wiki write rejected: content failed admission gate"));
|
||||||
}
|
}
|
||||||
|
|
@ -1151,7 +1011,6 @@ impl RdsWikiLayer {
|
||||||
|
|
||||||
let client = self.connect().await?;
|
let client = self.connect().await?;
|
||||||
|
|
||||||
// Determine next revision.
|
|
||||||
let latest: Option<i64> = {
|
let latest: Option<i64> = {
|
||||||
let row = client
|
let row = client
|
||||||
.query_opt(
|
.query_opt(
|
||||||
|
|
@ -1184,7 +1043,6 @@ impl RdsWikiLayer {
|
||||||
let archive_id = archive_record["archive_id"].as_str().unwrap_or("").to_string();
|
let archive_id = archive_record["archive_id"].as_str().unwrap_or("").to_string();
|
||||||
let content_hash = archive_record["content_hash"].as_str().unwrap_or("").to_string();
|
let content_hash = archive_record["content_hash"].as_str().unwrap_or("").to_string();
|
||||||
|
|
||||||
// Insert revision.
|
|
||||||
client
|
client
|
||||||
.execute(
|
.execute(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -1211,7 +1069,6 @@ impl RdsWikiLayer {
|
||||||
.await
|
.await
|
||||||
.context("inserting wiki revision")?;
|
.context("inserting wiki revision")?;
|
||||||
|
|
||||||
// Upsert page header.
|
|
||||||
client
|
client
|
||||||
.execute(
|
.execute(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -1228,29 +1085,20 @@ impl RdsWikiLayer {
|
||||||
.await
|
.await
|
||||||
.context("upserting wiki page")?;
|
.context("upserting wiki page")?;
|
||||||
|
|
||||||
// Replace links.
|
|
||||||
client
|
client
|
||||||
.execute(
|
.execute("DELETE FROM ene.wiki_links WHERE slug = $1", &[&slug])
|
||||||
"DELETE FROM ene.wiki_links WHERE slug = $1",
|
|
||||||
&[&slug],
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
for link_title in &links {
|
for link_title in &links {
|
||||||
let target_slug = title_slug(link_title).unwrap_or_default();
|
let target_slug = title_slug(link_title).unwrap_or_default();
|
||||||
client
|
client
|
||||||
.execute(
|
.execute(
|
||||||
r#"
|
r#"INSERT INTO ene.wiki_links (slug, target_slug, target_title) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING"#,
|
||||||
INSERT INTO ene.wiki_links (slug, target_slug, target_title)
|
|
||||||
VALUES ($1,$2,$3)
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
"#,
|
|
||||||
&[&slug, &target_slug, link_title],
|
&[&slug, &target_slug, link_title],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.context("inserting wiki link")?;
|
.context("inserting wiki link")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace categories.
|
|
||||||
client
|
client
|
||||||
.execute(
|
.execute(
|
||||||
"DELETE FROM ene.wiki_categories WHERE slug = $1",
|
"DELETE FROM ene.wiki_categories WHERE slug = $1",
|
||||||
|
|
@ -1267,7 +1115,6 @@ impl RdsWikiLayer {
|
||||||
.context("inserting wiki category")?;
|
.context("inserting wiki category")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert packages.
|
|
||||||
let data = &jsonl_event["data"];
|
let data = &jsonl_event["data"];
|
||||||
let pkg = data["pkg"].as_str().unwrap_or("");
|
let pkg = data["pkg"].as_str().unwrap_or("");
|
||||||
let version = data["version"].as_str().unwrap_or("");
|
let version = data["version"].as_str().unwrap_or("");
|
||||||
|
|
@ -1336,17 +1183,12 @@ impl RdsWikiLayer {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve the latest revision of a page by title, or `None` if not found.
|
|
||||||
pub async fn get_page(&self, title: &str) -> Result<Option<WikiRevision>> {
|
pub async fn get_page(&self, title: &str) -> Result<Option<WikiRevision>> {
|
||||||
let slug = title_slug(title)?;
|
let slug = title_slug(title)?;
|
||||||
let client = self.connect().await?;
|
let client = self.connect().await?;
|
||||||
|
|
||||||
// Find latest revision number.
|
|
||||||
let latest = client
|
let latest = client
|
||||||
.query_opt(
|
.query_opt("SELECT latest_revision FROM ene.wiki_pages WHERE slug = $1", &[&slug])
|
||||||
"SELECT latest_revision FROM ene.wiki_pages WHERE slug = $1",
|
|
||||||
&[&slug],
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.context("querying wiki page")?;
|
.context("querying wiki page")?;
|
||||||
let revision: i64 = match latest {
|
let revision: i64 = match latest {
|
||||||
|
|
@ -1354,14 +1196,9 @@ impl RdsWikiLayer {
|
||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch the revision body.
|
|
||||||
let row = client
|
let row = client
|
||||||
.query_opt(
|
.query_opt(
|
||||||
r#"
|
r#"SELECT title, slug, revision, text, author, summary, created_at_ms, receipt FROM ene.wiki_revisions WHERE slug = $1 AND revision = $2"#,
|
||||||
SELECT title, slug, revision, text, author, summary, created_at_ms, receipt
|
|
||||||
FROM ene.wiki_revisions
|
|
||||||
WHERE slug = $1 AND revision = $2
|
|
||||||
"#,
|
|
||||||
&[&slug, &revision],
|
&[&slug, &revision],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -1374,7 +1211,6 @@ impl RdsWikiLayer {
|
||||||
|
|
||||||
let slug_db: String = row.get(1);
|
let slug_db: String = row.get(1);
|
||||||
|
|
||||||
// Fetch links.
|
|
||||||
let link_rows = client
|
let link_rows = client
|
||||||
.query(
|
.query(
|
||||||
"SELECT target_title FROM ene.wiki_links WHERE slug = $1 ORDER BY lower(target_title)",
|
"SELECT target_title FROM ene.wiki_links WHERE slug = $1 ORDER BY lower(target_title)",
|
||||||
|
|
@ -1383,7 +1219,6 @@ impl RdsWikiLayer {
|
||||||
.await?;
|
.await?;
|
||||||
let links: Vec<String> = link_rows.iter().map(|r| r.get(0)).collect();
|
let links: Vec<String> = link_rows.iter().map(|r| r.get(0)).collect();
|
||||||
|
|
||||||
// Fetch categories.
|
|
||||||
let cat_rows = client
|
let cat_rows = client
|
||||||
.query(
|
.query(
|
||||||
"SELECT category FROM ene.wiki_categories WHERE slug = $1 ORDER BY lower(category)",
|
"SELECT category FROM ene.wiki_categories WHERE slug = $1 ORDER BY lower(category)",
|
||||||
|
|
@ -1407,9 +1242,6 @@ impl RdsWikiLayer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rusqlite optional helper ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Extension trait to turn "not found" rusqlite errors into `Option::None`.
|
|
||||||
trait OptionalExt<T> {
|
trait OptionalExt<T> {
|
||||||
fn optional(self) -> Result<Option<T>>;
|
fn optional(self) -> Result<Option<T>>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,623 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""ENE Context MCP surface.
|
|
||||||
|
|
||||||
Local-first MCP shim that gives ENE a ContextStream-like interface without
|
|
||||||
depending on ContextStream. It fronts the existing ENE API/session-sync surfaces
|
|
||||||
when they are running and keeps a small local SQLite memory ledger so writes are
|
|
||||||
available immediately.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
SERVER_NAME = "ene-contextstream"
|
|
||||||
SERVER_VERSION = "0.1.0"
|
|
||||||
DEFAULT_API_URL = "http://127.0.0.1:3000"
|
|
||||||
DEFAULT_STORE = Path.home() / ".local/share/ene/contextstream.sqlite"
|
|
||||||
DEFAULT_CANDIDATE_ROOT = (
|
|
||||||
Path.cwd() / "shared-data/data/germane/research/github-ene-contextstream"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def now_ms() -> int:
|
|
||||||
return int(time.time() * 1000)
|
|
||||||
|
|
||||||
|
|
||||||
def sha256_text(text: str) -> str:
|
|
||||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def json_text(data: Any) -> list[dict[str, str]]:
|
|
||||||
return [{"type": "text", "text": json.dumps(data, indent=2, sort_keys=True)}]
|
|
||||||
|
|
||||||
|
|
||||||
def store_path() -> Path:
|
|
||||||
return Path(os.environ.get("ENE_CONTEXT_STORE", str(DEFAULT_STORE))).expanduser()
|
|
||||||
|
|
||||||
|
|
||||||
def api_url() -> str:
|
|
||||||
return os.environ.get("ENE_API_URL", DEFAULT_API_URL).rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
def connect_store() -> sqlite3.Connection:
|
|
||||||
path = store_path()
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
conn = sqlite3.connect(path)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS memories (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
agent TEXT NOT NULL,
|
|
||||||
key TEXT NOT NULL,
|
|
||||||
value_json TEXT NOT NULL,
|
|
||||||
value_text TEXT NOT NULL,
|
|
||||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
|
||||||
kind TEXT NOT NULL DEFAULT 'note',
|
|
||||||
source TEXT NOT NULL DEFAULT 'mcp',
|
|
||||||
prev_hash TEXT,
|
|
||||||
receipt_hash TEXT NOT NULL,
|
|
||||||
created_at_ms INTEGER NOT NULL,
|
|
||||||
updated_at_ms INTEGER NOT NULL,
|
|
||||||
UNIQUE(agent, key)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_agent ON memories(agent)")
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key)")
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_updated ON memories(updated_at_ms DESC)")
|
|
||||||
conn.commit()
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
def parse_value(raw: Any) -> tuple[Any, str]:
|
|
||||||
if isinstance(raw, str):
|
|
||||||
try:
|
|
||||||
value = json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
value = raw
|
|
||||||
else:
|
|
||||||
value = raw
|
|
||||||
text = json.dumps(value, sort_keys=True) if not isinstance(value, str) else value
|
|
||||||
return value, text
|
|
||||||
|
|
||||||
|
|
||||||
def http_json(path: str, query: dict[str, Any] | None = None, timeout: float = 3.0) -> Any:
|
|
||||||
url = f"{api_url()}{path}"
|
|
||||||
if query:
|
|
||||||
url = f"{url}?{urllib.parse.urlencode(query)}"
|
|
||||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
||||||
status = response.status
|
|
||||||
content_type = response.headers.get("content-type", "")
|
|
||||||
text = response.read().decode("utf-8", errors="replace")
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
status = exc.code
|
|
||||||
content_type = exc.headers.get("content-type", "")
|
|
||||||
text = exc.read().decode("utf-8", errors="replace")
|
|
||||||
try:
|
|
||||||
data = json.loads(text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"error": "non-json response from ENE API",
|
|
||||||
"status": status,
|
|
||||||
"content_type": content_type,
|
|
||||||
"body": text,
|
|
||||||
"url": url,
|
|
||||||
}
|
|
||||||
if isinstance(data, dict):
|
|
||||||
data.setdefault("status", status)
|
|
||||||
data.setdefault("url", url)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def tool_status(_: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
conn = connect_store()
|
|
||||||
row = conn.execute("SELECT COUNT(*), MAX(updated_at_ms) FROM memories").fetchone()
|
|
||||||
api = {"ok": False, "url": api_url()}
|
|
||||||
try:
|
|
||||||
api = http_json("/health")
|
|
||||||
api["url"] = api_url()
|
|
||||||
except Exception as exc: # local status should not fail the MCP call
|
|
||||||
api["error"] = f"{type(exc).__name__}: {exc}"
|
|
||||||
|
|
||||||
sync_bin = os.environ.get(
|
|
||||||
"ENE_SESSION_SYNC_BIN",
|
|
||||||
str(Path.cwd() / "4-Infrastructure/infra/ene-rds/target/release/ene-sync"),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"server": SERVER_NAME,
|
|
||||||
"version": SERVER_VERSION,
|
|
||||||
"api": api,
|
|
||||||
"local_store": {
|
|
||||||
"path": str(store_path()),
|
|
||||||
"memory_count": row[0],
|
|
||||||
"last_updated_ms": row[1],
|
|
||||||
},
|
|
||||||
"session_sync": {
|
|
||||||
"path": sync_bin,
|
|
||||||
"exists": Path(sync_bin).exists(),
|
|
||||||
},
|
|
||||||
"candidate_import_root": str(
|
|
||||||
Path(os.environ.get("ENE_CONTEXT_CANDIDATE_ROOT", str(DEFAULT_CANDIDATE_ROOT)))
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def tool_remember(args: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
agent = str(args.get("agent") or "codex")
|
|
||||||
key = str(args["key"])
|
|
||||||
value, value_text = parse_value(args.get("value"))
|
|
||||||
tags = args.get("tags") or []
|
|
||||||
if isinstance(tags, str):
|
|
||||||
tags = [t.strip() for t in tags.split(",") if t.strip()]
|
|
||||||
kind = str(args.get("kind") or "note")
|
|
||||||
source = str(args.get("source") or "mcp")
|
|
||||||
ts = now_ms()
|
|
||||||
|
|
||||||
conn = connect_store()
|
|
||||||
prev = conn.execute(
|
|
||||||
"SELECT receipt_hash FROM memories WHERE agent = ? ORDER BY updated_at_ms DESC LIMIT 1",
|
|
||||||
(agent,),
|
|
||||||
).fetchone()
|
|
||||||
prev_hash = prev[0] if prev else None
|
|
||||||
payload = json.dumps(
|
|
||||||
{
|
|
||||||
"agent": agent,
|
|
||||||
"key": key,
|
|
||||||
"value": value,
|
|
||||||
"tags": tags,
|
|
||||||
"kind": kind,
|
|
||||||
"source": source,
|
|
||||||
"prev_hash": prev_hash,
|
|
||||||
"updated_at_ms": ts,
|
|
||||||
},
|
|
||||||
sort_keys=True,
|
|
||||||
)
|
|
||||||
receipt = sha256_text(payload)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO memories
|
|
||||||
(agent, key, value_json, value_text, tags_json, kind, source,
|
|
||||||
prev_hash, receipt_hash, created_at_ms, updated_at_ms)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(agent, key) DO UPDATE SET
|
|
||||||
value_json = excluded.value_json,
|
|
||||||
value_text = excluded.value_text,
|
|
||||||
tags_json = excluded.tags_json,
|
|
||||||
kind = excluded.kind,
|
|
||||||
source = excluded.source,
|
|
||||||
prev_hash = excluded.prev_hash,
|
|
||||||
receipt_hash = excluded.receipt_hash,
|
|
||||||
updated_at_ms = excluded.updated_at_ms
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
agent,
|
|
||||||
key,
|
|
||||||
json.dumps(value, sort_keys=True),
|
|
||||||
value_text,
|
|
||||||
json.dumps(tags, sort_keys=True),
|
|
||||||
kind,
|
|
||||||
source,
|
|
||||||
prev_hash,
|
|
||||||
receipt,
|
|
||||||
ts,
|
|
||||||
ts,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"agent": agent,
|
|
||||||
"key": key,
|
|
||||||
"kind": kind,
|
|
||||||
"tags": tags,
|
|
||||||
"receipt_hash": receipt,
|
|
||||||
"prev_hash": prev_hash,
|
|
||||||
"store": str(store_path()),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def row_to_memory(row: sqlite3.Row) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"agent": row["agent"],
|
|
||||||
"key": row["key"],
|
|
||||||
"value": json.loads(row["value_json"]),
|
|
||||||
"tags": json.loads(row["tags_json"]),
|
|
||||||
"kind": row["kind"],
|
|
||||||
"source": row["source"],
|
|
||||||
"receipt_hash": row["receipt_hash"],
|
|
||||||
"prev_hash": row["prev_hash"],
|
|
||||||
"created_at_ms": row["created_at_ms"],
|
|
||||||
"updated_at_ms": row["updated_at_ms"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def tool_recall(args: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
agent = str(args.get("agent") or "codex")
|
|
||||||
key = args.get("key")
|
|
||||||
query = str(args.get("query") or "")
|
|
||||||
limit = int(args.get("limit") or 10)
|
|
||||||
conn = connect_store()
|
|
||||||
|
|
||||||
if key is not None:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM memories WHERE agent = ? AND key = ?",
|
|
||||||
(agent, str(key)),
|
|
||||||
).fetchone()
|
|
||||||
return {"ok": True, "found": row is not None, "memory": row_to_memory(row) if row else None}
|
|
||||||
|
|
||||||
needle = f"%{query}%"
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM memories
|
|
||||||
WHERE agent = ? AND (? = '' OR key LIKE ? OR value_text LIKE ? OR tags_json LIKE ?)
|
|
||||||
ORDER BY updated_at_ms DESC
|
|
||||||
LIMIT ?
|
|
||||||
""",
|
|
||||||
(agent, query, needle, needle, needle, limit),
|
|
||||||
).fetchall()
|
|
||||||
return {"ok": True, "count": len(rows), "memories": [row_to_memory(r) for r in rows]}
|
|
||||||
|
|
||||||
|
|
||||||
def tool_search(args: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
query = str(args["query"])
|
|
||||||
limit = int(args.get("limit") or 10)
|
|
||||||
semantic = bool(args.get("semantic") or False)
|
|
||||||
sources = args.get("sources") or ["ene_api", "wiki", "local_memory"]
|
|
||||||
out: dict[str, Any] = {"ok": True, "query": query, "results": {}}
|
|
||||||
|
|
||||||
if "ene_api" in sources:
|
|
||||||
try:
|
|
||||||
out["results"]["ene_api"] = http_json(
|
|
||||||
"/search",
|
|
||||||
{"q": query, "limit": limit, "semantic": str(semantic).lower()},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
out["results"]["ene_api"] = {
|
|
||||||
"ok": False,
|
|
||||||
"error": f"{type(exc).__name__}: {exc}",
|
|
||||||
"url": api_url(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if "wiki" in sources:
|
|
||||||
try:
|
|
||||||
out["results"]["wiki"] = http_json(
|
|
||||||
"/wiki/search",
|
|
||||||
{"q": query, "limit": limit},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
out["results"]["wiki"] = {
|
|
||||||
"ok": False,
|
|
||||||
"error": f"{type(exc).__name__}: {exc}",
|
|
||||||
"url": api_url(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if "local_memory" in sources:
|
|
||||||
out["results"]["local_memory"] = tool_recall(
|
|
||||||
{"agent": args.get("agent", "codex"), "query": query, "limit": limit}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def tool_context(args: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""One-call session-start context packet for agents.
|
|
||||||
|
|
||||||
This mirrors ContextStream's common startup habit while staying ENE-first:
|
|
||||||
status + search + optional recall + optional transcript save.
|
|
||||||
"""
|
|
||||||
user_message = str(args.get("user_message") or args.get("query") or "")
|
|
||||||
agent = str(args.get("agent") or "codex")
|
|
||||||
save_exchange = bool(args.get("save_exchange") or False)
|
|
||||||
session_id = args.get("session_id")
|
|
||||||
result: dict[str, Any] = {
|
|
||||||
"ok": True,
|
|
||||||
"policy": "ENE first. ContextStream is fallback only.",
|
|
||||||
"status": tool_status({}),
|
|
||||||
"search": None,
|
|
||||||
"recall": None,
|
|
||||||
"saved": None,
|
|
||||||
}
|
|
||||||
if user_message:
|
|
||||||
result["search"] = tool_search({"query": user_message, "agent": agent, "limit": args.get("limit", 10)})
|
|
||||||
result["recall"] = tool_recall({"agent": agent, "query": user_message, "limit": args.get("limit", 10)})
|
|
||||||
if save_exchange and user_message:
|
|
||||||
key = f"session:{session_id or now_ms()}:user:{now_ms()}"
|
|
||||||
result["saved"] = tool_remember(
|
|
||||||
{
|
|
||||||
"agent": agent,
|
|
||||||
"key": key,
|
|
||||||
"value": {"user_message": user_message, "session_id": session_id},
|
|
||||||
"tags": ["transcript", "user-message"],
|
|
||||||
"kind": "conversation",
|
|
||||||
"source": "ene_context",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def tool_sessions(args: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
action = str(args.get("action") or "list")
|
|
||||||
limit = int(args.get("limit") or 10)
|
|
||||||
try:
|
|
||||||
if action == "list":
|
|
||||||
return http_json("/sessions", {"limit": limit})
|
|
||||||
if action == "get":
|
|
||||||
return http_json(f"/sessions/{urllib.parse.quote(str(args['session_id']))}")
|
|
||||||
except Exception as exc:
|
|
||||||
return {"ok": False, "error": f"{type(exc).__name__}: {exc}", "url": api_url()}
|
|
||||||
return {"ok": False, "error": f"unknown sessions action: {action}"}
|
|
||||||
|
|
||||||
|
|
||||||
def tool_sync(args: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
dry_run = bool(args.get("dry_run", True))
|
|
||||||
command = str(args.get("command") or "list")
|
|
||||||
sync_bin = os.environ.get(
|
|
||||||
"ENE_SESSION_SYNC_BIN",
|
|
||||||
str(Path.cwd() / "4-Infrastructure/infra/ene-rds/target/release/ene-sync"),
|
|
||||||
)
|
|
||||||
cmd = [sync_bin]
|
|
||||||
if command == "sync":
|
|
||||||
cmd.append("sync")
|
|
||||||
if args.get("embed"):
|
|
||||||
cmd.insert(1, "--embed")
|
|
||||||
elif command == "list":
|
|
||||||
cmd.extend(["list", "--limit", str(int(args.get("limit") or 20))])
|
|
||||||
elif command == "init-schema":
|
|
||||||
cmd.append("init-schema")
|
|
||||||
else:
|
|
||||||
return {"ok": False, "error": f"unsupported sync command: {command}"}
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
return {"ok": True, "dry_run": True, "command": cmd, "exists": Path(sync_bin).exists()}
|
|
||||||
if not Path(sync_bin).exists():
|
|
||||||
return {"ok": False, "error": "ene-session-sync binary not found", "command": cmd}
|
|
||||||
proc = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
|
||||||
return {
|
|
||||||
"ok": proc.returncode == 0,
|
|
||||||
"returncode": proc.returncode,
|
|
||||||
"stdout": proc.stdout[-8000:],
|
|
||||||
"stderr": proc.stderr[-8000:],
|
|
||||||
"command": cmd,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def candidate_summary(path: Path) -> dict[str, Any]:
|
|
||||||
readme = next(path.glob("README*"), None)
|
|
||||||
cargo = path / "Cargo.toml"
|
|
||||||
package = path / "package.json"
|
|
||||||
pyproject = path / "pyproject.toml"
|
|
||||||
return {
|
|
||||||
"repo": path.name,
|
|
||||||
"path": str(path),
|
|
||||||
"readme": str(readme) if readme else None,
|
|
||||||
"manifests": [str(p) for p in [cargo, package, pyproject] if p.exists()],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def tool_import_candidates(args: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
root = Path(args.get("root") or os.environ.get("ENE_CONTEXT_CANDIDATE_ROOT", str(DEFAULT_CANDIDATE_ROOT)))
|
|
||||||
repos = [p for p in sorted(root.iterdir()) if p.is_dir() and (p / ".git").exists()] if root.exists() else []
|
|
||||||
map_notes = {
|
|
||||||
"Octopoda-OS": "Agent memory API, remember/recall/search/snapshot/audit vocabulary.",
|
|
||||||
"llm_wiki": "Source -> wiki -> graph ingest pattern and local API shape.",
|
|
||||||
"SurfSense": "Browser/Obsidian/document connectors and team RAG workflows.",
|
|
||||||
"forge": "Local tool-calling guardrails and proxy agent loop patterns.",
|
|
||||||
"kanbots": "MCP over local HTTP bridge and agent task board semantics.",
|
|
||||||
"namidb": "Graph database on object storage; useful for ENE graph persistence.",
|
|
||||||
"Vane": "Local answering engine with SearXNG/search history/citation UX.",
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"root": str(root),
|
|
||||||
"count": len(repos),
|
|
||||||
"candidates": [
|
|
||||||
{**candidate_summary(p), "ene_use": map_notes.get(p.name, "Review manually")}
|
|
||||||
for p in repos
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
TOOLS: dict[str, dict[str, Any]] = {
|
|
||||||
"ene_status": {
|
|
||||||
"description": "ENE context health: local memory ledger, ENE API, session-sync binary.",
|
|
||||||
"inputSchema": {"type": "object", "properties": {}},
|
|
||||||
"handler": tool_status,
|
|
||||||
},
|
|
||||||
"ene_remember": {
|
|
||||||
"description": "Store a durable ENE memory packet with a hash-chain receipt.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"agent": {"type": "string", "default": "codex"},
|
|
||||||
"key": {"type": "string"},
|
|
||||||
"value": {},
|
|
||||||
"tags": {"type": "array", "items": {"type": "string"}},
|
|
||||||
"kind": {"type": "string", "default": "note"},
|
|
||||||
"source": {"type": "string", "default": "mcp"},
|
|
||||||
},
|
|
||||||
"required": ["key", "value"],
|
|
||||||
},
|
|
||||||
"handler": tool_remember,
|
|
||||||
},
|
|
||||||
"ene_context": {
|
|
||||||
"description": "ENE-first startup/context packet: status, search, recall, optional transcript save.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"user_message": {"type": "string"},
|
|
||||||
"query": {"type": "string"},
|
|
||||||
"agent": {"type": "string", "default": "codex"},
|
|
||||||
"session_id": {"type": "string"},
|
|
||||||
"save_exchange": {"type": "boolean", "default": False},
|
|
||||||
"limit": {"type": "integer", "default": 10},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"handler": tool_context,
|
|
||||||
},
|
|
||||||
"ene_recall": {
|
|
||||||
"description": "Recall ENE memory by exact key or query over local memory.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"agent": {"type": "string", "default": "codex"},
|
|
||||||
"key": {"type": "string"},
|
|
||||||
"query": {"type": "string"},
|
|
||||||
"limit": {"type": "integer", "default": 10},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"handler": tool_recall,
|
|
||||||
},
|
|
||||||
"ene_search": {
|
|
||||||
"description": "Search ENE API chat/session memory plus local ENE memory ledger.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"query": {"type": "string"},
|
|
||||||
"limit": {"type": "integer", "default": 10},
|
|
||||||
"semantic": {"type": "boolean", "default": False},
|
|
||||||
"agent": {"type": "string", "default": "codex"},
|
|
||||||
"sources": {"type": "array", "items": {"type": "string"}},
|
|
||||||
},
|
|
||||||
"required": ["query"],
|
|
||||||
},
|
|
||||||
"handler": tool_search,
|
|
||||||
},
|
|
||||||
"ene_sessions": {
|
|
||||||
"description": "List or fetch ENE chat sessions through ene-api.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"action": {"type": "string", "enum": ["list", "get"], "default": "list"},
|
|
||||||
"session_id": {"type": "string"},
|
|
||||||
"limit": {"type": "integer", "default": 10},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"handler": tool_sessions,
|
|
||||||
},
|
|
||||||
"ene_sync": {
|
|
||||||
"description": "Run or dry-run ene-session-sync commands.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"command": {"type": "string", "enum": ["list", "sync", "init-schema"], "default": "list"},
|
|
||||||
"dry_run": {"type": "boolean", "default": True},
|
|
||||||
"limit": {"type": "integer", "default": 20},
|
|
||||||
"embed": {"type": "boolean", "default": False},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"handler": tool_sync,
|
|
||||||
},
|
|
||||||
"ene_import_candidates": {
|
|
||||||
"description": "List GitHub repos pulled as ENE ContextStream-equivalent candidates.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"root": {"type": "string"}},
|
|
||||||
},
|
|
||||||
"handler": tool_import_candidates,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def mcp_tools() -> list[dict[str, Any]]:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"description": spec["description"],
|
|
||||||
"inputSchema": spec["inputSchema"],
|
|
||||||
}
|
|
||||||
for name, spec in TOOLS.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def handle(req: dict[str, Any]) -> dict[str, Any] | None:
|
|
||||||
method = req.get("method")
|
|
||||||
req_id = req.get("id")
|
|
||||||
try:
|
|
||||||
if method == "initialize":
|
|
||||||
return {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": req_id,
|
|
||||||
"result": {
|
|
||||||
"protocolVersion": req.get("params", {}).get("protocolVersion", "2024-11-05"),
|
|
||||||
"capabilities": {"tools": {}},
|
|
||||||
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if method == "notifications/initialized":
|
|
||||||
return None
|
|
||||||
if method == "tools/list":
|
|
||||||
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": mcp_tools()}}
|
|
||||||
if method == "tools/call":
|
|
||||||
params = req.get("params", {})
|
|
||||||
name = params.get("name")
|
|
||||||
if name not in TOOLS:
|
|
||||||
raise ValueError(f"unknown tool: {name}")
|
|
||||||
args = params.get("arguments") or {}
|
|
||||||
result = TOOLS[name]["handler"](args)
|
|
||||||
return {"jsonrpc": "2.0", "id": req_id, "result": {"content": json_text(result)}}
|
|
||||||
return {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": req_id,
|
|
||||||
"error": {"code": -32601, "message": f"method not found: {method}"},
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
return {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": req_id,
|
|
||||||
"error": {"code": -32000, "message": f"{type(exc).__name__}: {exc}"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
if len(sys.argv) > 1:
|
|
||||||
command = sys.argv[1]
|
|
||||||
if command in {"--status", "status"}:
|
|
||||||
print(json.dumps(tool_status({}), indent=2, sort_keys=True))
|
|
||||||
return 0
|
|
||||||
if command in {"--context", "context"}:
|
|
||||||
query = " ".join(sys.argv[2:])
|
|
||||||
print(json.dumps(tool_context({"user_message": query}), indent=2, sort_keys=True))
|
|
||||||
return 0
|
|
||||||
print(f"unknown command: {command}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
handled = 0
|
|
||||||
for line in sys.stdin:
|
|
||||||
if not line.strip():
|
|
||||||
continue
|
|
||||||
handled += 1
|
|
||||||
try:
|
|
||||||
response = handle(json.loads(line))
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
response = {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": None,
|
|
||||||
"error": {"code": -32700, "message": str(exc)},
|
|
||||||
}
|
|
||||||
if response is not None:
|
|
||||||
sys.stdout.write(json.dumps(response, separators=(",", ":")) + "\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
if handled == 0:
|
|
||||||
print(json.dumps(tool_status({}), indent=2, sort_keys=True))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
132
4-Infrastructure/shim/clean_rrc_pist_validation.py
Normal file
132
4-Infrastructure/shim/clean_rrc_pist_validation.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Clean shared-data/rrc_pist_exact_validation.json after regeneration.
|
||||||
|
|
||||||
|
The legacy validation script can accidentally include Markdown table artifacts such
|
||||||
|
as `Equation` and `---` as predictions. This cleaner removes those rows, rebuilds
|
||||||
|
the summary, and preserves classifier-backed predictions for the receipt-density
|
||||||
|
injector.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
python3 4-Infrastructure/shim/clean_rrc_pist_validation.py
|
||||||
|
|
||||||
|
Optional paths:
|
||||||
|
|
||||||
|
python3 4-Infrastructure/shim/clean_rrc_pist_validation.py \
|
||||||
|
--input shared-data/rrc_pist_exact_validation.json \
|
||||||
|
--out shared-data/rrc_pist_exact_validation.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_REPORT = ROOT / "shared-data/rrc_pist_exact_validation.json"
|
||||||
|
NOISE = {"", "---", "Equation", "RRC shape", "Status", "Top axes"}
|
||||||
|
|
||||||
|
|
||||||
|
def is_noise(pred: dict[str, Any]) -> bool:
|
||||||
|
eq = str(pred.get("equation", ""))
|
||||||
|
gt = str(pred.get("ground_truth", ""))
|
||||||
|
proxy = str(pred.get("proxy_pred", ""))
|
||||||
|
return (
|
||||||
|
eq in NOISE
|
||||||
|
or gt in NOISE
|
||||||
|
or proxy in NOISE
|
||||||
|
or bool(re.fullmatch(r"-+", eq))
|
||||||
|
or bool(re.fullmatch(r"-+", gt))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def accuracy(predictions: list[dict[str, Any]], key: str) -> float:
|
||||||
|
if not predictions:
|
||||||
|
return 0.0
|
||||||
|
return sum(1 for pred in predictions if pred.get(key) == pred.get("ground_truth")) / len(predictions)
|
||||||
|
|
||||||
|
|
||||||
|
def per_class(predictions: list[dict[str, Any]], key: str) -> dict[str, dict[str, Any]]:
|
||||||
|
classes = sorted({p.get("ground_truth") for p in predictions} | {p.get(key) for p in predictions})
|
||||||
|
out: dict[str, dict[str, Any]] = {}
|
||||||
|
for cls in classes:
|
||||||
|
if cls is None:
|
||||||
|
continue
|
||||||
|
total = sum(1 for p in predictions if p.get("ground_truth") == cls)
|
||||||
|
correct = sum(1 for p in predictions if p.get("ground_truth") == cls and p.get(key) == cls)
|
||||||
|
out[str(cls)] = {"total": total, "correct": correct, "accuracy": correct / total if total else 0.0}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def zmp_distribution(predictions: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||||
|
zmp_by_gt: dict[str, list[int]] = defaultdict(list)
|
||||||
|
for pred in predictions:
|
||||||
|
gt = str(pred.get("ground_truth", "unknown"))
|
||||||
|
try:
|
||||||
|
zmp = int(pred.get("zmp", 0))
|
||||||
|
except Exception:
|
||||||
|
zmp = 0
|
||||||
|
zmp_by_gt[gt].append(zmp)
|
||||||
|
return {
|
||||||
|
gt: {"mean": sum(vals) / len(vals), "min": min(vals), "max": max(vals), "unique": len(set(vals))}
|
||||||
|
for gt, vals in zmp_by_gt.items()
|
||||||
|
if vals
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clean_report(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
raw_predictions = data.get("predictions", [])
|
||||||
|
predictions = [pred for pred in raw_predictions if isinstance(pred, dict) and not is_noise(pred)]
|
||||||
|
dropped = len(raw_predictions) - len(predictions)
|
||||||
|
|
||||||
|
matrix_hashes = Counter(str(p.get("matrix_hash", "")) for p in predictions if p.get("matrix_hash"))
|
||||||
|
canonical_hashes = Counter(str(p.get("canonical_hash", "")) for p in predictions if p.get("canonical_hash"))
|
||||||
|
errors = data.get("errors_detail", []) or []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"total_input_predictions_before_clean": len(raw_predictions),
|
||||||
|
"markdown_noise_predictions_dropped": dropped,
|
||||||
|
"total": len(predictions),
|
||||||
|
"errors": len(errors),
|
||||||
|
"unique_matrix_hashes": len(matrix_hashes),
|
||||||
|
"unique_canonical_hashes": len(canonical_hashes),
|
||||||
|
"proxy_accuracy": accuracy(predictions, "proxy_pred"),
|
||||||
|
"exact_accuracy": accuracy(predictions, "exact_pred"),
|
||||||
|
"matrix_hash_collisions": sum(1 for count in matrix_hashes.values() if count > 1),
|
||||||
|
"canonical_hash_collisions": sum(1 for count in canonical_hashes.values() if count > 1),
|
||||||
|
"filtered_markdown_noise": True,
|
||||||
|
"promotion_policy": "not_promoted classifier diagnostics only",
|
||||||
|
},
|
||||||
|
"per_class_proxy": per_class(predictions, "proxy_pred"),
|
||||||
|
"per_class_exact": per_class(predictions, "exact_pred"),
|
||||||
|
"zmp_distribution": zmp_distribution(predictions),
|
||||||
|
"errors_detail": errors,
|
||||||
|
"predictions": predictions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Clean RRC PIST validation JSON")
|
||||||
|
parser.add_argument("--input", type=Path, default=DEFAULT_REPORT)
|
||||||
|
parser.add_argument("--out", type=Path, default=DEFAULT_REPORT)
|
||||||
|
parser.add_argument("--fail-if-empty", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
data = json.loads(args.input.read_text(encoding="utf-8"))
|
||||||
|
cleaned = clean_report(data)
|
||||||
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.out.write_text(json.dumps(cleaned, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
print(json.dumps(cleaned["summary"], indent=2, sort_keys=True))
|
||||||
|
print(f"Wrote cleaned report: {args.out}")
|
||||||
|
if args.fail_if_empty and cleaned["summary"]["total"] == 0:
|
||||||
|
return 2
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -1,123 +1,126 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""ENE substrate migration + concept tagging (legacy shim).
|
||||||
Migrate knowledge.* tables → ENE substrate with aggressive concept tagging.
|
|
||||||
|
|
||||||
1. Apply ENE schema (ene.packages + 9 support tables)
|
NOTE (ontology migration):
|
||||||
2. Migrate all sources into ene.packages with typed provenance
|
|
||||||
3. Extract concepts, build relations, score N-space KV retention
|
|
||||||
4. Run cross-source discovery queries
|
|
||||||
|
|
||||||
Run in dev container:
|
This file is a **legacy shim** used to keep ENE substrate bootstrapping and
|
||||||
podman exec -e AWS_ACCESS_KEY_ID=... -e AWS_SECRET_ACCESS_KEY=... -e AWS_REGION=us-east-1 -e RDS_IAM=1 \
|
migration workflows running while the AVM / Lean-only ISA rewrite is underway.
|
||||||
research-stack python3 /home/researcher/stack/4-Infrastructure/shim/ene_migrate_and_tag.py
|
|
||||||
|
**Target architecture:** Lean-only AVM ISA + backend shims.
|
||||||
|
- Lean defines all semantics.
|
||||||
|
- Shims perform I/O and orchestration only.
|
||||||
|
|
||||||
|
This script currently performs:
|
||||||
|
- schema application
|
||||||
|
- migration SQL
|
||||||
|
- tokenizer-based tagging
|
||||||
|
- relation building
|
||||||
|
- retention scoring
|
||||||
|
|
||||||
|
Several of those operations are semantic decisions and include float-based scoring.
|
||||||
|
They must be ported into Lean/AVM.
|
||||||
|
|
||||||
|
Rules until ported:
|
||||||
|
- Treat all outputs as **not promoted**.
|
||||||
|
- Never silently apply a different schema. If the schema file is missing: **reject**.
|
||||||
|
|
||||||
|
TODO(lean-port): Port tagging/relations/retention scoring into Lean/AVM.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
|
|
||||||
from rds_connect import connect_rds
|
from rds_connect import connect_rds
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
log = logging.getLogger("ene_migrate")
|
log = logging.getLogger("ene_migrate")
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_SCHEMA_PATH = REPO_ROOT / "4-Infrastructure/shim/ene_substrate_schema.sql"
|
||||||
|
ONTOLOGY_VERSION = "shim-ontology-migration-v1"
|
||||||
|
|
||||||
|
|
||||||
def connect():
|
def connect():
|
||||||
return connect_rds()
|
return connect_rds()
|
||||||
|
|
||||||
|
|
||||||
def apply_schema(conn):
|
def apply_schema(conn, schema_path: Path) -> None:
|
||||||
"""Apply the full ENE substrate schema."""
|
"""Apply the ENE substrate schema.
|
||||||
schema_path = Path("/home/researcher/stack/4-Infrastructure/shim/ene_substrate_schema.sql")
|
|
||||||
if schema_path.exists():
|
Policy: never silently substitute an inline schema. If the schema file is
|
||||||
sql = schema_path.read_text()
|
missing, reject.
|
||||||
with conn.cursor() as cur:
|
"""
|
||||||
cur.execute(sql)
|
if not schema_path.exists():
|
||||||
conn.commit()
|
raise FileNotFoundError(f"ENE schema file not found: {schema_path}")
|
||||||
log.info("ENE substrate schema applied")
|
|
||||||
else:
|
sql = schema_path.read_text(encoding="utf-8")
|
||||||
log.warning("Schema file not found, creating inline")
|
with conn.cursor() as cur:
|
||||||
# Fallback: create minimal schema inline
|
cur.execute(sql)
|
||||||
with conn.cursor() as cur:
|
conn.commit()
|
||||||
cur.execute("CREATE SCHEMA IF NOT EXISTS ene")
|
log.info("ENE substrate schema applied (%s)", schema_path)
|
||||||
cur.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS ene.packages (
|
|
||||||
pkg TEXT PRIMARY KEY, package_type TEXT, title TEXT, content TEXT,
|
|
||||||
content_hash TEXT, concept_vector JSONB DEFAULT '[]',
|
|
||||||
concept_anchor JSONB DEFAULT '{}', tags JSONB DEFAULT '[]',
|
|
||||||
source TEXT, provenance JSONB DEFAULT '{}', domain TEXT,
|
|
||||||
archetype TEXT, promotion_state TEXT DEFAULT 'held',
|
|
||||||
scar_class TEXT, ingested_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS ene.relations (
|
|
||||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
||||||
source_id TEXT NOT NULL, target_id TEXT NOT NULL,
|
|
||||||
relation_type TEXT NOT NULL, weight REAL DEFAULT 1.0,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS ene.nspace_kv (
|
|
||||||
key_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
||||||
value_package_id TEXT NOT NULL,
|
|
||||||
reduction_reward REAL DEFAULT 0, sparsity_score REAL DEFAULT 0,
|
|
||||||
scar_pressure REAL DEFAULT 0, retention_score REAL DEFAULT 0
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
conn.commit()
|
|
||||||
log.info("ENE minimal schema applied")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Term extraction (same aggressive tokenizer from concept_cross_reference.py)
|
# Term extraction (legacy tokenizer)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
MATH_SYMBOL_RE = re.compile(
|
MATH_SYMBOL_RE = re.compile(
|
||||||
r'\\(?:alpha|beta|gamma|Gamma|delta|Delta|epsilon|varepsilon|zeta|eta|theta|Theta|'
|
r"\\\\(?:alpha|beta|gamma|Gamma|delta|Delta|epsilon|varepsilon|zeta|eta|theta|Theta|"
|
||||||
r'iota|kappa|lambda|Lambda|mu|nu|xi|Xi|pi|Pi|rho|sigma|Sigma|tau|upsilon|phi|Phi|'
|
r"iota|kappa|lambda|Lambda|mu|nu|xi|Xi|pi|Pi|rho|sigma|Sigma|tau|upsilon|phi|Phi|"
|
||||||
r'varphi|chi|psi|Psi|omega|Omega|partial|nabla|infty|int|sum|prod|otimes|oplus|'
|
r"varphi|chi|psi|Psi|omega|Omega|partial|nabla|infty|int|sum|prod|otimes|oplus|"
|
||||||
r'rightarrow|leftarrow|Rightarrow|Leftarrow|mapsto|approx|equiv|sim|propto|'
|
r"rightarrow|leftarrow|Rightarrow|Leftarrow|mapsto|approx|equiv|sim|propto|"
|
||||||
r'leq|geq|neq|times|cdot|circ|pm|mp|sqrt|frac|operatorname|mathbf|mathrm|mathcal|'
|
r"leq|geq|neq|times|cdot|circ|pm|mp|sqrt|frac|operatorname|mathbf|mathrm|mathcal|"
|
||||||
r'mathfrak|mathbb|text|hat|tilde|bar|vec|dot|ddot|widehat|widetilde|'
|
r"mathfrak|mathbb|text|hat|tilde|bar|vec|dot|ddot|widehat|widetilde|"
|
||||||
r'langle|rangle|lVert|rVert|vert|mid|'
|
r"langle|rangle|lVert|rVert|vert|mid|"
|
||||||
r'begin|end|left|right|big|Big|bigg|Bigg)'
|
r"begin|end|left|right|big|Big|bigg|Bigg)"
|
||||||
)
|
)
|
||||||
|
|
||||||
TECHNICAL_RE = re.compile(
|
TECHNICAL_RE = re.compile(
|
||||||
r'\b(?:'
|
r"\\b(?:"
|
||||||
r'manifold|field|shear|packet|spectral|braid|gossip|'
|
r"manifold|field|shear|packet|spectral|braid|gossip|"
|
||||||
r'residual|invariant|receipt|scar|warden|collapse|compression|'
|
r"residual|invariant|receipt|scar|warden|collapse|compression|"
|
||||||
r'entropy|eigen(?:value|vector)?|coboundary|cochain|'
|
r"entropy|eigen(?:value|vector)?|coboundary|cochain|"
|
||||||
r'diffusion|transport|boundary|kernel|operator|'
|
r"diffusion|transport|boundary|kernel|operator|"
|
||||||
r'tensor|metric|geodesic|curvature|torsion|'
|
r"tensor|metric|geodesic|curvature|torsion|"
|
||||||
r'hamiltonian|lagrangian|reduction|projection|embedding|'
|
r"hamiltonian|lagrangian|reduction|projection|embedding|"
|
||||||
r'chirality|helicity|handedness|logogram|'
|
r"chirality|helicity|handedness|logogram|"
|
||||||
r'sidon|goxel|famm|nuvmap|otom|pist|'
|
r"sidon|goxel|famm|nuvmap|otom|pist|"
|
||||||
r'erdos|szekeres|selfridge|gyarfas|'
|
r"erdos|szekeres|selfridge|gyarfas|"
|
||||||
r'biocompression|organoid|chaos|fractal|attractor|basin|'
|
r"biocompression|organoid|chaos|fractal|attractor|basin|"
|
||||||
r'thermal|thermodynamic|landauer|witness|shadow|adversarial|'
|
r"thermal|thermodynamic|landauer|witness|shadow|adversarial|"
|
||||||
r'morph(?:ic|ism)?|radix|codec|semantic|'
|
r"morph(?:ic|ism)?|radix|codec|semantic|"
|
||||||
r'markov|cognitive|attention|neural|network|transformer|'
|
r"markov|cognitive|attention|neural|network|transformer|"
|
||||||
r'hutter|prize|betti|homology|cohomology|'
|
r"hutter|prize|betti|homology|cohomology|"
|
||||||
r'riccati|noise|mfg|hessian|jacobian|'
|
r"riccati|noise|mfg|hessian|jacobian|"
|
||||||
r'seam|tomography|sandwich|pruning|rope|scar|'
|
r"seam|tomography|sandwich|pruning|rope|scar|"
|
||||||
r'eigensolid|eigenspace|underverse|geocognition|'
|
r"eigensolid|eigenspace|underverse|geocognition|"
|
||||||
r'smallcode|constrained|key.value|shortcut|ontology|'
|
r"smallcode|constrained|key.value|shortcut|ontology|"
|
||||||
r'hyperbolic|riemannian|poincare|'
|
r"hyperbolic|riemannian|poincare|"
|
||||||
r'bio|dna|rna|protein|feynman|navier|stokes|'
|
r"bio|dna|rna|protein|feynman|navier|stokes|"
|
||||||
r'plasma|mhd|alfven|'
|
r"plasma|mhd|alfven|"
|
||||||
r'q16|fixed.point|subleq|oisc|kv|cache'
|
r"q16|fixed.point|subleq|oisc|kv|cache"
|
||||||
r')\b', re.IGNORECASE
|
r")\\b",
|
||||||
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
TOKEN_RE = re.compile(r'[a-zA-Z_\\][a-zA-Z0-9_\\]*|\b(?:N-space|key-value|CP-SAT|Anti-FAMM|Anti-Braid)\b', re.IGNORECASE)
|
TOKEN_RE = re.compile(
|
||||||
STOP_WORDS = set("the a an is are was were be been being have has had do does did will would shall should may might must can could of in to for with on at by from as into through during and but or not no nor so if then else when where this that these those it its we they he she which who whom whose what how why also very more most some any all each every both few new other such only own same just about over text bf rm sf tt em sc it up use using used can one two also etc via per e.g i.e figure table section non doi url http https www paper result method approach model data set page pages vol pp et al note notes example see shown fig eq ref abstract introduction conclusion reference references arxiv org github com html pdf first second third following based given found obtained described proposed well within without between among under above below since however therefore thus still yet here there where now then than get got getting make made making take taken taking give given giving let lets case cases term terms form forms number numbers value values point points part parts type types kind kinds way ways much many long short high low large small different similar same total whole full work works working need needs needed help helps helped like likes liked know known unknown think thought believe want wants wanted try tries tried".split())
|
r"[a-zA-Z_\\\\][a-zA-Z0-9_\\\\]*|\\b(?:N-space|key-value|CP-SAT|Anti-FAMM|Anti-Braid)\\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
STOP_WORDS = set(
|
||||||
|
"the a an is are was were be been being have has had do does did will would shall should may might must can could of in to for with on at by from as into through during and but or not no nor so if then else when where this that these those it its we they which who what how why also more most some any all each every both few new other such only own same just about over text rm sf tt em sc up use using used one two etc via per eg ie figure table section doi url http https www paper result method approach model data set page pages vol pp et al note notes example see shown fig eq ref abstract introduction conclusion reference references arxiv org github com html pdf first second third following based given found obtained described proposed well within without between among under above below since however therefore thus still yet here there now then than"
|
||||||
|
.split()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def tokenize(text: str) -> list[str]:
|
def tokenize(text: str) -> list[str]:
|
||||||
|
|
@ -126,7 +129,14 @@ def tokenize(text: str) -> list[str]:
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
results: list[str] = []
|
results: list[str] = []
|
||||||
for m in TOKEN_RE.finditer(text):
|
for m in TOKEN_RE.finditer(text):
|
||||||
t = m.group(0).strip().lower().rstrip(".,;:!?\"'()[]{}").lstrip("\\").rstrip("{}")
|
t = (
|
||||||
|
m.group(0)
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
.rstrip(".,;:!?\"'()[]{}")
|
||||||
|
.lstrip("\\\\")
|
||||||
|
.rstrip("{}")
|
||||||
|
)
|
||||||
if not t or t in STOP_WORDS or len(t) < 2 or t in seen:
|
if not t or t in STOP_WORDS or len(t) < 2 or t in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(t)
|
seen.add(t)
|
||||||
|
|
@ -137,13 +147,15 @@ def tokenize(text: str) -> list[str]:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Migration
|
# Migration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def migrate_all_sources(conn):
|
def migrate_all_sources(conn) -> int:
|
||||||
"""Migrate knowledge.* tables into ene.packages with typed provenance."""
|
"""Migrate knowledge.* tables into ene.packages with typed provenance."""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
total = 0
|
total = 0
|
||||||
|
|
||||||
migrations = [
|
migrations = [
|
||||||
("equations", "eq_id", "equation", """
|
(
|
||||||
|
"equations",
|
||||||
|
"""
|
||||||
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
||||||
SELECT eq_id::text, 'equation', latex, latex, content_hash, '[]'::jsonb, source_file, 'equation_corpus',
|
SELECT eq_id::text, 'equation', latex, latex, content_hash, '[]'::jsonb, source_file, 'equation_corpus',
|
||||||
jsonb_build_object('kind', kind, 'source_file', source_file, 'source_offset', source_offset),
|
jsonb_build_object('kind', kind, 'source_file', source_file, 'source_offset', source_offset),
|
||||||
|
|
@ -152,8 +164,11 @@ def migrate_all_sources(conn):
|
||||||
ON CONFLICT (pkg) DO UPDATE SET
|
ON CONFLICT (pkg) DO UPDATE SET
|
||||||
title = EXCLUDED.title, content = EXCLUDED.content,
|
title = EXCLUDED.title, content = EXCLUDED.content,
|
||||||
content_hash = EXCLUDED.content_hash, tags = EXCLUDED.tags
|
content_hash = EXCLUDED.content_hash, tags = EXCLUDED.tags
|
||||||
"""),
|
""",
|
||||||
("tiddlywiki_pages", "tiddler_id", "tiddler", """
|
),
|
||||||
|
(
|
||||||
|
"tiddlywiki_pages",
|
||||||
|
"""
|
||||||
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
||||||
SELECT tiddler_id::text, 'tiddler', title, coalesce(body,''),
|
SELECT tiddler_id::text, 'tiddler', title, coalesce(body,''),
|
||||||
content_hash, '[]'::jsonb, source_path, 'tiddlywiki',
|
content_hash, '[]'::jsonb, source_path, 'tiddlywiki',
|
||||||
|
|
@ -163,10 +178,13 @@ def migrate_all_sources(conn):
|
||||||
ON CONFLICT (pkg) DO UPDATE SET
|
ON CONFLICT (pkg) DO UPDATE SET
|
||||||
title = EXCLUDED.title, content = EXCLUDED.content,
|
title = EXCLUDED.title, content = EXCLUDED.content,
|
||||||
content_hash = EXCLUDED.content_hash, tags = EXCLUDED.tags
|
content_hash = EXCLUDED.content_hash, tags = EXCLUDED.tags
|
||||||
"""),
|
""",
|
||||||
("references", "ref_id", "reference", """
|
),
|
||||||
|
(
|
||||||
|
"references",
|
||||||
|
"""
|
||||||
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
||||||
SELECT ref_id::text, 'reference',
|
SELECT ref_id::text, 'reference',
|
||||||
substring(coalesce(bibtex,'') from 1 for 200),
|
substring(coalesce(bibtex,'') from 1 for 200),
|
||||||
bibtex, content_hash, '[]'::jsonb, source_file, 'bibliography',
|
bibtex, content_hash, '[]'::jsonb, source_file, 'bibliography',
|
||||||
jsonb_build_object('source_file', source_file, 'bibtex', coalesce(bibtex,'')),
|
jsonb_build_object('source_file', source_file, 'bibtex', coalesce(bibtex,'')),
|
||||||
|
|
@ -175,8 +193,11 @@ def migrate_all_sources(conn):
|
||||||
ON CONFLICT (pkg) DO UPDATE SET
|
ON CONFLICT (pkg) DO UPDATE SET
|
||||||
title = EXCLUDED.title, content = EXCLUDED.content,
|
title = EXCLUDED.title, content = EXCLUDED.content,
|
||||||
content_hash = EXCLUDED.content_hash, tags = EXCLUDED.tags
|
content_hash = EXCLUDED.content_hash, tags = EXCLUDED.tags
|
||||||
"""),
|
""",
|
||||||
("links", "link_id", "link", """
|
),
|
||||||
|
(
|
||||||
|
"links",
|
||||||
|
"""
|
||||||
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
||||||
SELECT link_id::text, 'link', url, url, encode(sha256(url::bytea),'hex'), '[]'::jsonb, source_file,
|
SELECT link_id::text, 'link', url, url, encode(sha256(url::bytea),'hex'), '[]'::jsonb, source_file,
|
||||||
'external_reference',
|
'external_reference',
|
||||||
|
|
@ -185,8 +206,11 @@ def migrate_all_sources(conn):
|
||||||
FROM knowledge.links
|
FROM knowledge.links
|
||||||
ON CONFLICT (pkg) DO UPDATE SET
|
ON CONFLICT (pkg) DO UPDATE SET
|
||||||
title = EXCLUDED.title, content = EXCLUDED.content
|
title = EXCLUDED.title, content = EXCLUDED.content
|
||||||
"""),
|
""",
|
||||||
("article_sources", "article_id", "article", """
|
),
|
||||||
|
(
|
||||||
|
"article_sources",
|
||||||
|
"""
|
||||||
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
||||||
SELECT article_id::text, 'article', coalesce(label, url), coalesce(label,'') || ' ' || url,
|
SELECT article_id::text, 'article', coalesce(label, url), coalesce(label,'') || ' ' || url,
|
||||||
encode(sha256(url::bytea),'hex'), '[]'::jsonb, url, 'article_source',
|
encode(sha256(url::bytea),'hex'), '[]'::jsonb, url, 'article_source',
|
||||||
|
|
@ -195,8 +219,11 @@ def migrate_all_sources(conn):
|
||||||
FROM knowledge.article_sources
|
FROM knowledge.article_sources
|
||||||
ON CONFLICT (pkg) DO UPDATE SET
|
ON CONFLICT (pkg) DO UPDATE SET
|
||||||
title = EXCLUDED.title, content = EXCLUDED.content
|
title = EXCLUDED.title, content = EXCLUDED.content
|
||||||
"""),
|
""",
|
||||||
("dois", "doi_id", "doi", """
|
),
|
||||||
|
(
|
||||||
|
"dois",
|
||||||
|
"""
|
||||||
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
||||||
SELECT doi_id::text, 'doi', doi, doi, encode(sha256(doi::bytea),'hex'), '[]'::jsonb, source_file, 'doi_identifier',
|
SELECT doi_id::text, 'doi', doi, doi, encode(sha256(doi::bytea),'hex'), '[]'::jsonb, source_file, 'doi_identifier',
|
||||||
jsonb_build_object('doi', doi, 'source_file', source_file),
|
jsonb_build_object('doi', doi, 'source_file', source_file),
|
||||||
|
|
@ -204,8 +231,11 @@ def migrate_all_sources(conn):
|
||||||
FROM knowledge.dois
|
FROM knowledge.dois
|
||||||
ON CONFLICT (pkg) DO UPDATE SET
|
ON CONFLICT (pkg) DO UPDATE SET
|
||||||
title = EXCLUDED.title, content = EXCLUDED.content
|
title = EXCLUDED.title, content = EXCLUDED.content
|
||||||
"""),
|
""",
|
||||||
("dataset_inventory", "inv_id", "dataset", """
|
),
|
||||||
|
(
|
||||||
|
"dataset_inventory",
|
||||||
|
"""
|
||||||
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
INSERT INTO ene.packages (pkg, package_type, title, content, content_hash, tags, source, domain, provenance, promotion_state)
|
||||||
SELECT inv_id::text, 'dataset', coalesce(name, asset_id),
|
SELECT inv_id::text, 'dataset', coalesce(name, asset_id),
|
||||||
coalesce(name,'') || ' ' || coalesce(evidence,'') || ' ' || coalesce(notes,''),
|
coalesce(name,'') || ' ' || coalesce(evidence,'') || ' ' || coalesce(notes,''),
|
||||||
|
|
@ -216,12 +246,13 @@ def migrate_all_sources(conn):
|
||||||
FROM knowledge.dataset_inventory
|
FROM knowledge.dataset_inventory
|
||||||
ON CONFLICT (pkg) DO UPDATE SET
|
ON CONFLICT (pkg) DO UPDATE SET
|
||||||
title = EXCLUDED.title, content = EXCLUDED.content
|
title = EXCLUDED.title, content = EXCLUDED.content
|
||||||
"""),
|
""",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
for src_table, id_col, pkg_type, insert_sql in migrations:
|
for src_table, insert_sql in migrations:
|
||||||
cur.execute(f"SELECT COUNT(*) FROM knowledge.{src_table}")
|
cur.execute(f"SELECT COUNT(*) FROM knowledge.{src_table}")
|
||||||
count = cur.fetchone()[0]
|
count = int(cur.fetchone()[0])
|
||||||
cur.execute(insert_sql)
|
cur.execute(insert_sql)
|
||||||
total += count
|
total += count
|
||||||
log.info("Migrated %s → ene.packages (%d rows)", src_table, count)
|
log.info("Migrated %s → ene.packages (%d rows)", src_table, count)
|
||||||
|
|
@ -231,11 +262,16 @@ def migrate_all_sources(conn):
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
def tag_packages(conn):
|
def tag_packages(conn) -> int:
|
||||||
"""Extract terms from each package content and store as concept_vector + tags JSONB."""
|
"""Extract terms from package content and store concept_vector + tags JSONB.
|
||||||
|
|
||||||
|
NOTE: This is semantic classification logic and must be ported to Lean/AVM.
|
||||||
|
"""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("SELECT pkg, package_type, coalesce(content,''), coalesce(title,'') FROM ene.packages WHERE content IS NOT NULL AND content != ''")
|
cur.execute(
|
||||||
|
"SELECT pkg, package_type, coalesce(content,''), coalesce(title,'') FROM ene.packages WHERE content IS NOT NULL AND content != ''"
|
||||||
|
)
|
||||||
packages = cur.fetchall()
|
packages = cur.fetchall()
|
||||||
|
|
||||||
updated = 0
|
updated = 0
|
||||||
|
|
@ -244,16 +280,18 @@ def tag_packages(conn):
|
||||||
if not terms:
|
if not terms:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Classify terms
|
|
||||||
math_terms = [t for t in terms if MATH_SYMBOL_RE.fullmatch(t)]
|
math_terms = [t for t in terms if MATH_SYMBOL_RE.fullmatch(t)]
|
||||||
tech_terms = [t for t in terms if TECHNICAL_RE.search(t)]
|
tech_terms = [t for t in terms if TECHNICAL_RE.search(t)]
|
||||||
all_terms = list(dict.fromkeys(terms)) # deduplicate preserving order
|
all_terms = list(dict.fromkeys(terms))
|
||||||
|
|
||||||
concept_vector = [
|
concept_vector = [
|
||||||
{"term": t, "type": "math_symbol" if t in math_terms else "technical_term" if t in tech_terms else "keyword"}
|
{
|
||||||
for t in all_terms[:50] # cap at 50 for storage
|
"term": t,
|
||||||
|
"type": "math_symbol" if t in math_terms else "technical_term" if t in tech_terms else "keyword",
|
||||||
|
}
|
||||||
|
for t in all_terms[:50]
|
||||||
]
|
]
|
||||||
tags = all_terms[:20] # top 20 as tags
|
tags = all_terms[:20]
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""UPDATE ene.packages
|
"""UPDATE ene.packages
|
||||||
|
|
@ -271,16 +309,19 @@ def tag_packages(conn):
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
def build_relations(conn):
|
def build_relations(conn) -> int:
|
||||||
"""Build ene.relations between packages that share concepts across different domains."""
|
"""Build ene.relations between packages that share concepts across domains.
|
||||||
|
|
||||||
|
NOTE: This is semantic graph construction logic and must be ported to Lean/AVM.
|
||||||
|
"""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
# Extract all concept terms per package
|
cur.execute(
|
||||||
cur.execute("SELECT pkg, concept_vector, domain FROM ene.packages WHERE concept_vector IS NOT NULL AND jsonb_array_length(concept_vector) > 0")
|
"SELECT pkg, concept_vector, domain FROM ene.packages WHERE concept_vector IS NOT NULL AND jsonb_array_length(concept_vector) > 0"
|
||||||
|
)
|
||||||
packages = cur.fetchall()
|
packages = cur.fetchall()
|
||||||
log.info("Building relations from %d tagged packages…", len(packages))
|
log.info("Building relations from %d tagged packages…", len(packages))
|
||||||
|
|
||||||
# Index: term -> [(pkg, domain), ...]
|
|
||||||
term_index: dict[str, list[tuple[str, str]]] = {}
|
term_index: dict[str, list[tuple[str, str]]] = {}
|
||||||
for pkg, cv, domain in packages:
|
for pkg, cv, domain in packages:
|
||||||
if cv is None:
|
if cv is None:
|
||||||
|
|
@ -288,35 +329,27 @@ def build_relations(conn):
|
||||||
concepts = json.loads(cv) if isinstance(cv, str) else cv
|
concepts = json.loads(cv) if isinstance(cv, str) else cv
|
||||||
for c in concepts:
|
for c in concepts:
|
||||||
term = c["term"].lower()
|
term = c["term"].lower()
|
||||||
if term not in term_index:
|
term_index.setdefault(term, []).append((pkg, domain or "unknown"))
|
||||||
term_index[term] = []
|
|
||||||
term_index[term].append((pkg, domain or "unknown"))
|
|
||||||
|
|
||||||
# Build relations: packages sharing concepts across different domains
|
|
||||||
relation_count = 0
|
relation_count = 0
|
||||||
for term, pkgs in term_index.items():
|
for term, pkgs in term_index.items():
|
||||||
if len(pkgs) < 2:
|
if len(pkgs) < 2:
|
||||||
continue
|
continue
|
||||||
# Pair packages from different domains sharing this term
|
|
||||||
for i, (p1, d1) in enumerate(pkgs):
|
for i, (p1, d1) in enumerate(pkgs):
|
||||||
for j in range(i + 1, len(pkgs)):
|
for j in range(i + 1, len(pkgs)):
|
||||||
p2, d2 = pkgs[j]
|
p2, d2 = pkgs[j]
|
||||||
if d1 == d2:
|
if d1 == d2:
|
||||||
continue # skip same-domain (already known)
|
continue
|
||||||
# Determine relation type
|
rel_type = "shares_concept"
|
||||||
rel_type = "shares_concept" if d1 != d2 else "co_occurs"
|
cur.execute(
|
||||||
try:
|
"""INSERT INTO ene.relations (source_id, target_id, relation_type, weight)
|
||||||
cur.execute(
|
VALUES (%s,%s,%s,1.0)
|
||||||
"""INSERT INTO ene.relations (source_id, target_id, relation_type, weight)
|
ON CONFLICT DO NOTHING""",
|
||||||
VALUES (%s,%s,%s,1.0)
|
(p1, p2, rel_type),
|
||||||
ON CONFLICT DO NOTHING""",
|
)
|
||||||
(p1, p2, rel_type),
|
if cur.rowcount > 0:
|
||||||
)
|
relation_count += 1
|
||||||
if cur.rowcount > 0:
|
if relation_count and relation_count % 2000 == 0:
|
||||||
relation_count += 1
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if relation_count % 2000 == 0:
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
log.info(" %d relations…", relation_count)
|
log.info(" %d relations…", relation_count)
|
||||||
|
|
||||||
|
|
@ -325,12 +358,16 @@ def build_relations(conn):
|
||||||
return relation_count
|
return relation_count
|
||||||
|
|
||||||
|
|
||||||
def score_nspace_kv(conn):
|
def score_nspace_kv(conn) -> int:
|
||||||
"""Compute reduction_reward, sparsity_score, and retention_score for packages."""
|
"""Compute retention scoring for packages.
|
||||||
|
|
||||||
|
WARNING: This function currently uses float arithmetic via Postgres casts.
|
||||||
|
It must be ported into Lean/AVM fixed-point semantics.
|
||||||
|
"""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
# Score based on: concept count (richness), relation count (connectivity), domain uniqueness
|
cur.execute(
|
||||||
cur.execute("""
|
"""
|
||||||
INSERT INTO ene.nspace_kv (value_package_id, reduction_reward, sparsity_score, scar_pressure, retention_score)
|
INSERT INTO ene.nspace_kv (value_package_id, reduction_reward, sparsity_score, scar_pressure, retention_score)
|
||||||
SELECT p.pkg,
|
SELECT p.pkg,
|
||||||
GREATEST(0.1, LEAST(1.0, jsonb_array_length(p.concept_vector) / 50.0)) AS reduction_reward,
|
GREATEST(0.1, LEAST(1.0, jsonb_array_length(p.concept_vector) / 50.0)) AS reduction_reward,
|
||||||
|
|
@ -350,20 +387,24 @@ def score_nspace_kv(conn):
|
||||||
reduction_reward = EXCLUDED.reduction_reward,
|
reduction_reward = EXCLUDED.reduction_reward,
|
||||||
sparsity_score = EXCLUDED.sparsity_score,
|
sparsity_score = EXCLUDED.sparsity_score,
|
||||||
retention_score = EXCLUDED.retention_score
|
retention_score = EXCLUDED.retention_score
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
cur.execute("SELECT COUNT(*) FROM ene.nspace_kv")
|
cur.execute("SELECT COUNT(*) FROM ene.nspace_kv")
|
||||||
nv = cur.fetchone()[0]
|
nv = int(cur.fetchone()[0])
|
||||||
log.info("Scored %d packages with N-space KV retention", nv)
|
log.info("Scored %d packages with N-space KV retention", nv)
|
||||||
return nv
|
return nv
|
||||||
|
|
||||||
|
|
||||||
def run_discovery_queries(conn):
|
def run_discovery_queries(conn) -> dict[str, list[tuple]]:
|
||||||
"""Run cross-source discovery queries and log unexpected groupings."""
|
"""Run cross-source discovery queries and log groupings."""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
queries = [
|
queries = [
|
||||||
("=== DOMAINS SHARING THE MOST CONCEPTS ===", """
|
(
|
||||||
|
"domains_sharing_most_concepts",
|
||||||
|
"""
|
||||||
SELECT r.relation_type, p1.domain AS domain_a, p2.domain AS domain_b,
|
SELECT r.relation_type, p1.domain AS domain_a, p2.domain AS domain_b,
|
||||||
COUNT(*) AS pair_count,
|
COUNT(*) AS pair_count,
|
||||||
COUNT(DISTINCT r.source_id) + COUNT(DISTINCT r.target_id) AS packages_involved
|
COUNT(DISTINCT r.source_id) + COUNT(DISTINCT r.target_id) AS packages_involved
|
||||||
|
|
@ -373,137 +414,85 @@ def run_discovery_queries(conn):
|
||||||
GROUP BY r.relation_type, p1.domain, p2.domain
|
GROUP BY r.relation_type, p1.domain, p2.domain
|
||||||
ORDER BY pair_count DESC
|
ORDER BY pair_count DESC
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
"""),
|
""",
|
||||||
|
),
|
||||||
("=== EQUATIONS BRIDGING TO TIDDLYWIKI PAGES ===", """
|
|
||||||
SELECT eq.pkg AS eq_pkg, LEFT(eq.title, 80) AS equation,
|
|
||||||
tw.title AS tiddler_title,
|
|
||||||
r.relation_type
|
|
||||||
FROM ene.relations r
|
|
||||||
JOIN ene.packages eq ON eq.pkg = r.source_id AND eq.domain = 'equation_corpus'
|
|
||||||
JOIN ene.packages tw ON tw.pkg = r.target_id AND tw.domain = 'tiddlywiki'
|
|
||||||
ORDER BY r.weight DESC
|
|
||||||
LIMIT 30
|
|
||||||
"""),
|
|
||||||
|
|
||||||
("=== UNEXPECTED CROSS-SOURCE GROUPINGS ===", """
|
|
||||||
WITH shared AS (
|
|
||||||
SELECT p1.domain AS d1, p2.domain AS d2, COUNT(*) AS cnt
|
|
||||||
FROM ene.relations r
|
|
||||||
JOIN ene.packages p1 ON p1.pkg = r.source_id
|
|
||||||
JOIN ene.packages p2 ON p2.pkg = r.target_id
|
|
||||||
WHERE p1.domain != p2.domain
|
|
||||||
GROUP BY p1.domain, p2.domain
|
|
||||||
)
|
|
||||||
SELECT d1, d2, cnt,
|
|
||||||
CASE WHEN cnt > 10 THEN 'strong'
|
|
||||||
WHEN cnt > 3 THEN 'notable'
|
|
||||||
ELSE 'weak'
|
|
||||||
END AS strength
|
|
||||||
FROM shared
|
|
||||||
ORDER BY cnt DESC
|
|
||||||
"""),
|
|
||||||
|
|
||||||
("=== TOP BRIDGE CONCEPTS (terms spanning most domains) ===", """
|
|
||||||
SELECT term, COUNT(DISTINCT p.domain) AS domain_span,
|
|
||||||
ARRAY_AGG(DISTINCT p.domain) AS domains
|
|
||||||
FROM (
|
|
||||||
SELECT p.domain, (jsonb_array_elements(p.concept_vector)->>'term') AS term
|
|
||||||
FROM ene.packages p
|
|
||||||
WHERE p.concept_vector IS NOT NULL AND jsonb_array_length(p.concept_vector) > 0
|
|
||||||
) sub
|
|
||||||
GROUP BY term
|
|
||||||
HAVING COUNT(DISTINCT domain) >= 3
|
|
||||||
ORDER BY domain_span DESC, COUNT(*) DESC
|
|
||||||
LIMIT 30
|
|
||||||
"""),
|
|
||||||
|
|
||||||
("=== HIGHEST RETENTION SCORE PACKAGES ===", """
|
|
||||||
SELECT n.value_package_id, p.title, p.domain, n.retention_score,
|
|
||||||
n.reduction_reward, n.sparsity_score
|
|
||||||
FROM ene.nspace_kv n
|
|
||||||
JOIN ene.packages p ON p.pkg = n.value_package_id
|
|
||||||
ORDER BY n.retention_score DESC
|
|
||||||
LIMIT 20
|
|
||||||
"""),
|
|
||||||
|
|
||||||
("=== DOMAINS BY PACKAGE COUNT ===", """
|
|
||||||
SELECT domain, COUNT(*) AS package_count, package_type,
|
|
||||||
COUNT(DISTINCT package_type) AS types
|
|
||||||
FROM ene.packages
|
|
||||||
GROUP BY domain, package_type
|
|
||||||
ORDER BY COUNT(*) DESC
|
|
||||||
"""),
|
|
||||||
|
|
||||||
("=== RELATION TYPE DISTRIBUTION ===", """
|
|
||||||
SELECT relation_type, COUNT(*) AS total,
|
|
||||||
COUNT(DISTINCT source_id) AS sources,
|
|
||||||
COUNT(DISTINCT target_id) AS targets
|
|
||||||
FROM ene.relations
|
|
||||||
GROUP BY relation_type
|
|
||||||
ORDER BY total DESC
|
|
||||||
"""),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
results = {}
|
results: dict[str, list[tuple]] = {}
|
||||||
for title, query in queries:
|
for key, query in queries:
|
||||||
cur.execute(query)
|
cur.execute(query)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
results[title] = rows
|
results[key] = rows
|
||||||
log.info("\n%s", title)
|
log.info("Query %s returned %d rows", key, len(rows))
|
||||||
for row in rows[:12]:
|
|
||||||
log.info(" %s", " | ".join(str(c) for c in row))
|
|
||||||
if len(rows) > 12:
|
|
||||||
log.info(" ... +%d more rows", len(rows) - 12)
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def main(argv: list[str] | None = None) -> int:
|
||||||
# Main
|
parser = argparse.ArgumentParser(description="Migrate knowledge.* tables → ENE substrate with concept tagging.")
|
||||||
# ---------------------------------------------------------------------------
|
parser.add_argument("--schema", type=Path, default=DEFAULT_SCHEMA_PATH)
|
||||||
def main():
|
parser.add_argument("--skip-tagging", action="store_true")
|
||||||
|
parser.add_argument("--skip-relations", action="store_true")
|
||||||
|
parser.add_argument("--skip-retention", action="store_true")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
log.info("Connecting to RDS…")
|
log.info("Connecting to RDS…")
|
||||||
conn = connect()
|
conn = connect()
|
||||||
conn.autocommit = False
|
conn.autocommit = False
|
||||||
|
|
||||||
log.info("Phase 1: Apply ENE substrate schema")
|
try:
|
||||||
apply_schema(conn)
|
log.info("Phase 1: Apply ENE substrate schema")
|
||||||
|
apply_schema(conn, args.schema)
|
||||||
|
|
||||||
log.info("Phase 2: Migrate knowledge.* → ene.packages")
|
log.info("Phase 2: Migrate knowledge.* → ene.packages")
|
||||||
total = migrate_all_sources(conn)
|
migrate_all_sources(conn)
|
||||||
|
|
||||||
log.info("Phase 3: Extract concepts and tag packages")
|
if not args.skip_tagging:
|
||||||
tagged = tag_packages(conn)
|
log.info("Phase 3: Extract concepts and tag packages")
|
||||||
|
tag_packages(conn)
|
||||||
|
|
||||||
log.info("Phase 4: Build cross-domain relations")
|
if not args.skip_relations:
|
||||||
relations = build_relations(conn)
|
log.info("Phase 4: Build cross-domain relations")
|
||||||
|
build_relations(conn)
|
||||||
|
|
||||||
log.info("Phase 5: Score N-space KV retention")
|
if not args.skip_retention:
|
||||||
nv_scored = score_nspace_kv(conn)
|
log.info("Phase 5: Score N-space KV retention")
|
||||||
|
score_nspace_kv(conn)
|
||||||
|
|
||||||
log.info("Phase 6: Run discovery queries")
|
log.info("Phase 6: Run discovery queries")
|
||||||
results = run_discovery_queries(conn)
|
run_discovery_queries(conn)
|
||||||
|
|
||||||
# Summary
|
log.info("Done.")
|
||||||
cur = conn.cursor()
|
return 0
|
||||||
cur.execute("SELECT COUNT(*) FROM ene.packages")
|
|
||||||
pkg_count = cur.fetchone()[0]
|
|
||||||
cur.execute("SELECT COUNT(*) FROM ene.relations")
|
|
||||||
rel_count = cur.fetchone()[0]
|
|
||||||
cur.execute("SELECT domain, COUNT(*) FROM ene.packages GROUP BY domain ORDER BY COUNT(*) DESC")
|
|
||||||
domains = cur.fetchall()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
log.info("\n=== SUMMARY ===")
|
finally:
|
||||||
log.info("Packages: %d", pkg_count)
|
conn.close()
|
||||||
log.info("Relations: %d", rel_count)
|
|
||||||
log.info("N-space KV scored: %d", nv_scored)
|
|
||||||
log.info("Domains:")
|
|
||||||
for d, c in domains:
|
|
||||||
log.info(" %s: %d", d, c)
|
|
||||||
log.info("Done.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
raise SystemExit(main())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shim strip receipt (non-authoritative conversion surface)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
STRIP_RECEIPT = {
|
||||||
|
"ontology_version": ONTOLOGY_VERSION,
|
||||||
|
"shim_role": "legacy_ene_migration_and_tagging_pending_avm",
|
||||||
|
"computed_in_shim": [
|
||||||
|
"tokenize / concept tagging",
|
||||||
|
"relation building",
|
||||||
|
"retention scoring",
|
||||||
|
"schema orchestration",
|
||||||
|
],
|
||||||
|
"must_port_to_lean_avm": [
|
||||||
|
"tokenization policy (finite types, no open strings)",
|
||||||
|
"relation scoring (fixed-point)",
|
||||||
|
"retention scoring (fixed-point)",
|
||||||
|
"all semantic decisions",
|
||||||
|
],
|
||||||
|
"float_policy": {
|
||||||
|
"status": "float_present_in_sql_scoring",
|
||||||
|
"reason": "ene.nspace_kv scoring uses Postgres float casts; must be ported",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,25 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""PIST -> RRC receipt-density backfill injector.
|
"""PIST -> RRC receipt-density backfill injector.
|
||||||
|
|
||||||
This script converts existing RRC equation projection rows plus optional PIST
|
NOTE (ontology migration):
|
||||||
classification output into receipt-density records.
|
|
||||||
|
|
||||||
It is intentionally conservative:
|
This file is a **legacy shim**. It exists to keep historical backfill workflows
|
||||||
|
running while the AVM rewrite is underway.
|
||||||
|
|
||||||
* It DOES NOT promote equations.
|
**Target architecture:** Lean-only AVM ISA + backend shims.
|
||||||
* It DOES NOT mutate RDS/ENE by default.
|
- Lean defines all semantics.
|
||||||
* RDS writes require the explicit --write-rds flag.
|
- Shims do JSON/RDS I/O only.
|
||||||
* RDS writes default to a sidecar table: ene.rrc_receipt_density.
|
|
||||||
* Database connectivity is delegated to the shared rds_connect.connect_rds helper.
|
|
||||||
* It filters Markdown table header/separator rows that older validation scripts
|
|
||||||
accidentally treated as equations.
|
|
||||||
|
|
||||||
Default inputs:
|
This script still contains scoring math in Python (float-based) and therefore
|
||||||
|
MUST be treated as a non-authoritative conversion surface.
|
||||||
|
|
||||||
6-Documentation/docs/rrc_equation_classification.md
|
Rules until ported:
|
||||||
shared-data/rrc_pist_exact_validation.json
|
- Output is always `promotion = not_promoted`.
|
||||||
|
- Output must carry an explicit `strip_receipt` section explaining:
|
||||||
|
- which constructs were computed in shim space
|
||||||
|
- what must be ported to Lean/AVM
|
||||||
|
|
||||||
Default output:
|
TODO(lean-port): Replace all scoring and warning decisions with Lean/AVM.
|
||||||
|
|
||||||
shared-data/rrc_receipt_density_backfill.json
|
|
||||||
|
|
||||||
The output is a receipt-density surface, not a truth claim. A populated density
|
|
||||||
axis means "this equation has enough structural/witness evidence for routing";
|
|
||||||
it does not mean the equation is mathematically proved.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -34,7 +28,6 @@ import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
@ -53,6 +46,8 @@ DEFAULT_PIST_REPORT = REPO_ROOT / "shared-data/rrc_pist_exact_validation.json"
|
||||||
DEFAULT_OUT = REPO_ROOT / "shared-data/rrc_receipt_density_backfill.json"
|
DEFAULT_OUT = REPO_ROOT / "shared-data/rrc_receipt_density_backfill.json"
|
||||||
DEFAULT_RDS_TABLE = "ene.rrc_receipt_density"
|
DEFAULT_RDS_TABLE = "ene.rrc_receipt_density"
|
||||||
|
|
||||||
|
ONTOLOGY_VERSION = "shim-ontology-migration-v1"
|
||||||
|
|
||||||
TARGET_AXES = {
|
TARGET_AXES = {
|
||||||
"projection_declared",
|
"projection_declared",
|
||||||
"negative_control_strength",
|
"negative_control_strength",
|
||||||
|
|
@ -290,6 +285,7 @@ def build_record(row: RRCEquationRow, pred: dict[str, Any] | None) -> ReceiptDen
|
||||||
"top_axes": row.top_axes,
|
"top_axes": row.top_axes,
|
||||||
"promotion": "not_promoted",
|
"promotion": "not_promoted",
|
||||||
"source": "pist_receipt_density_injector_v1",
|
"source": "pist_receipt_density_injector_v1",
|
||||||
|
"ontology_version": ONTOLOGY_VERSION,
|
||||||
}
|
}
|
||||||
receipt_hash = stable_hash(unsigned_payload)
|
receipt_hash = stable_hash(unsigned_payload)
|
||||||
|
|
||||||
|
|
@ -325,6 +321,8 @@ def summarize(records: list[ReceiptDensityRecord], total_rows: int, prediction_c
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"receipt_version": "pist-receipt-density-v1",
|
"receipt_version": "pist-receipt-density-v1",
|
||||||
|
"ontology_version": ONTOLOGY_VERSION,
|
||||||
|
"shim_role": "legacy_scoring_surface_pending_avm",
|
||||||
"input_rows": total_rows,
|
"input_rows": total_rows,
|
||||||
"records": len(records),
|
"records": len(records),
|
||||||
"pist_predictions_loaded": prediction_count,
|
"pist_predictions_loaded": prediction_count,
|
||||||
|
|
@ -339,6 +337,10 @@ def summarize(records: list[ReceiptDensityRecord], total_rows: int, prediction_c
|
||||||
"by_status": dict(sorted(by_status.items())),
|
"by_status": dict(sorted(by_status.items())),
|
||||||
"warning_counts": dict(sorted(warning_counts.items())),
|
"warning_counts": dict(sorted(warning_counts.items())),
|
||||||
"promotion_policy": "no automatic promotion; density populates routing evidence only",
|
"promotion_policy": "no automatic promotion; density populates routing evidence only",
|
||||||
|
"float_policy": {
|
||||||
|
"status": "legacy_float_math_present",
|
||||||
|
"reason": "shim computes density components using Python float; must be ported to Lean/AVM",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -524,12 +526,31 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
|
"strip_receipt": {
|
||||||
|
"ontology_version": ONTOLOGY_VERSION,
|
||||||
|
"shim_role": "legacy_scoring_surface_pending_avm",
|
||||||
|
"computed_in_shim": [
|
||||||
|
"receipt_density",
|
||||||
|
"confidence",
|
||||||
|
"density_components",
|
||||||
|
"warnings",
|
||||||
|
],
|
||||||
|
"must_port_to_lean_avm": [
|
||||||
|
"compute_density",
|
||||||
|
"spectral_quality",
|
||||||
|
"shape_agreement",
|
||||||
|
"axis_score",
|
||||||
|
"status_score",
|
||||||
|
"warning assignment",
|
||||||
|
],
|
||||||
|
"float_policy": "legacy_float_math_present; reject once AVM port is active",
|
||||||
|
},
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"rrc_file": str(args.rrc_file),
|
"rrc_file": str(args.rrc_file),
|
||||||
"pist_report": str(args.pist_report),
|
"pist_report": str(args.pist_report),
|
||||||
},
|
},
|
||||||
"claim_boundary": {
|
"claim_boundary": {
|
||||||
"receipt_density_means": "routing evidence is populated",
|
"receipt_density_means": "routing evidence is populated (legacy shim surface)",
|
||||||
"receipt_density_does_not_mean": "mathematical proof or promotion",
|
"receipt_density_does_not_mean": "mathematical proof or promotion",
|
||||||
"promotion_policy": "not_promoted for every generated record",
|
"promotion_policy": "not_promoted for every generated record",
|
||||||
"rds_policy": "--write-rds upserts sidecar receipt-density metadata only via rds_connect.connect_rds",
|
"rds_policy": "--write-rds upserts sidecar receipt-density metadata only via rds_connect.connect_rds",
|
||||||
|
|
|
||||||
236
4-Infrastructure/shim/rrc_pist_shape_alignment.py
Normal file
236
4-Infrastructure/shim/rrc_pist_shape_alignment.py
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""RRC/PIST shape-alignment calibration pass.
|
||||||
|
|
||||||
|
NOTE (ontology migration):
|
||||||
|
|
||||||
|
This file is a **legacy shim**. It exists to keep historical alignment workflows
|
||||||
|
running while the AVM rewrite is underway.
|
||||||
|
|
||||||
|
**Target architecture:** Lean-only AVM ISA + backend shims.
|
||||||
|
- Lean defines all semantics.
|
||||||
|
- Shims do JSON I/O only.
|
||||||
|
|
||||||
|
This script still contains decision logic in Python (alignment classification +
|
||||||
|
warning rewrite). It therefore MUST be treated as a non-authoritative
|
||||||
|
conversion surface.
|
||||||
|
|
||||||
|
Rules until ported:
|
||||||
|
- Records remain `promotion = not_promoted`.
|
||||||
|
- Output must carry an explicit `strip_receipt` section explaining:
|
||||||
|
- which decisions were made in shim space
|
||||||
|
- what must be ported to Lean/AVM
|
||||||
|
|
||||||
|
TODO(lean-port): Replace determine_alignment + rewrite_warnings + hashing payload
|
||||||
|
with Lean/AVM execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Repo root (this file lives at 4-Infrastructure/shim/...)
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
DEFAULT_IN = ROOT / "shared-data/rrc_receipt_density_backfill.json"
|
||||||
|
DEFAULT_OUT = ROOT / "shared-data/rrc_receipt_density_backfill.json"
|
||||||
|
|
||||||
|
ONTOLOGY_VERSION = "shim-ontology-migration-v1"
|
||||||
|
|
||||||
|
RRC_SEMANTIC_SHAPES = {
|
||||||
|
"CognitiveLoadField",
|
||||||
|
"SignalShapedRouteCompiler",
|
||||||
|
"ProjectableGeometryTopology",
|
||||||
|
"CadForceProbeReceipt",
|
||||||
|
"LogogramProjection",
|
||||||
|
}
|
||||||
|
|
||||||
|
COMPATIBLE_STRUCTURAL_LABELS = {
|
||||||
|
"LogogramProjection",
|
||||||
|
}
|
||||||
|
|
||||||
|
ALIGNMENT_SCORES = {
|
||||||
|
"aligned_exact": 1.0,
|
||||||
|
"aligned_proxy": 0.86,
|
||||||
|
"compatible_structural_projection": 0.72,
|
||||||
|
"missing_prediction": 0.0,
|
||||||
|
"alignment_warning": 0.35,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stable_hash(payload: Any) -> str:
|
||||||
|
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def determine_alignment(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
rrc_shape = record.get("rrc_shape")
|
||||||
|
shape_prediction = record.get("shape_prediction", {}) or {}
|
||||||
|
proxy = shape_prediction.get("proxy_pred")
|
||||||
|
exact = shape_prediction.get("exact_pred")
|
||||||
|
|
||||||
|
if not proxy and not exact:
|
||||||
|
status = "missing_prediction"
|
||||||
|
reason = "No PIST proxy/exact classifier label is present for this record."
|
||||||
|
elif exact == rrc_shape:
|
||||||
|
status = "aligned_exact"
|
||||||
|
reason = "PIST exact structural label matches the RRC routing shape."
|
||||||
|
elif proxy == rrc_shape:
|
||||||
|
status = "aligned_proxy"
|
||||||
|
reason = "PIST proxy structural label matches the RRC routing shape."
|
||||||
|
elif (
|
||||||
|
(exact in COMPATIBLE_STRUCTURAL_LABELS or proxy in COMPATIBLE_STRUCTURAL_LABELS)
|
||||||
|
and rrc_shape in RRC_SEMANTIC_SHAPES
|
||||||
|
):
|
||||||
|
status = "compatible_structural_projection"
|
||||||
|
reason = "PIST detects symbolic/logogram morphology while RRC supplies the semantic/domain routing class."
|
||||||
|
else:
|
||||||
|
status = "alignment_warning"
|
||||||
|
reason = "PIST structural label and RRC semantic shape are not in the current compatibility map."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"alignment_version": "rrc-pist-shape-alignment-v1",
|
||||||
|
"alignment_status": status,
|
||||||
|
"alignment_confidence": ALIGNMENT_SCORES[status],
|
||||||
|
"rrc_shape": rrc_shape,
|
||||||
|
"pist_proxy_label": proxy,
|
||||||
|
"pist_exact_label": exact,
|
||||||
|
"label_space_model": "PIST=morphology; RRC=semantic routing",
|
||||||
|
"reason": reason,
|
||||||
|
"promotion": "not_promoted",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_warnings(warnings: list[str], alignment: dict[str, Any]) -> list[str]:
|
||||||
|
out = [warning for warning in warnings if warning != "pist_shape_disagreement"]
|
||||||
|
status = alignment["alignment_status"]
|
||||||
|
if status == "compatible_structural_projection":
|
||||||
|
out.append("structural_semantic_label_divergence")
|
||||||
|
elif status == "alignment_warning":
|
||||||
|
out.append("pist_shape_alignment_warning")
|
||||||
|
elif status == "missing_prediction":
|
||||||
|
out.append("missing_pist_prediction")
|
||||||
|
return sorted(set(out))
|
||||||
|
|
||||||
|
|
||||||
|
def update_hash(record: dict[str, Any]) -> str:
|
||||||
|
payload = {
|
||||||
|
"equation_id": record.get("equation_id"),
|
||||||
|
"rrc_shape": record.get("rrc_shape"),
|
||||||
|
"receipt_density": record.get("receipt_density"),
|
||||||
|
"confidence": record.get("confidence"),
|
||||||
|
"shape_prediction": record.get("shape_prediction"),
|
||||||
|
"shape_alignment": record.get("shape_alignment"),
|
||||||
|
"warnings": record.get("warnings"),
|
||||||
|
"promotion": "not_promoted",
|
||||||
|
"source": record.get("source"),
|
||||||
|
"ontology_version": ONTOLOGY_VERSION,
|
||||||
|
}
|
||||||
|
return stable_hash(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def align_payload(payload: dict[str, Any]) -> tuple[dict[str, Any], Counter[str]]:
|
||||||
|
records = payload.get("records", [])
|
||||||
|
if not isinstance(records, list):
|
||||||
|
raise ValueError("input JSON must contain a records array")
|
||||||
|
|
||||||
|
aligned_records: list[dict[str, Any]] = []
|
||||||
|
alignment_counts: Counter[str] = Counter()
|
||||||
|
warning_counts: Counter[str] = Counter()
|
||||||
|
raw_warning_counts: Counter[str] = Counter()
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_warning_counts.update(list(record.get("warnings", [])))
|
||||||
|
|
||||||
|
updated = dict(record)
|
||||||
|
updated["promotion"] = "not_promoted"
|
||||||
|
alignment = determine_alignment(updated)
|
||||||
|
updated["shape_alignment"] = alignment
|
||||||
|
updated["warnings"] = rewrite_warnings(list(updated.get("warnings", [])), alignment)
|
||||||
|
updated["receipt_hash"] = update_hash(updated)
|
||||||
|
|
||||||
|
alignment_counts[alignment["alignment_status"]] += 1
|
||||||
|
warning_counts.update(updated["warnings"])
|
||||||
|
aligned_records.append(updated)
|
||||||
|
|
||||||
|
summary = dict(payload.get("summary", {}))
|
||||||
|
summary["shape_alignment_version"] = "rrc-pist-shape-alignment-v1"
|
||||||
|
summary["shape_alignment_counts"] = dict(sorted(alignment_counts.items()))
|
||||||
|
summary["warning_counts"] = dict(sorted(warning_counts.items()))
|
||||||
|
summary["raw_warning_counts"] = dict(sorted(raw_warning_counts.items()))
|
||||||
|
summary["promotion_policy"] = "no automatic promotion; shape alignment calibrates label spaces only"
|
||||||
|
summary["ontology_version"] = ONTOLOGY_VERSION
|
||||||
|
summary["shim_role"] = "legacy_alignment_surface_pending_avm"
|
||||||
|
|
||||||
|
out = dict(payload)
|
||||||
|
out["summary"] = summary
|
||||||
|
out["strip_receipt"] = {
|
||||||
|
"ontology_version": ONTOLOGY_VERSION,
|
||||||
|
"shim_role": "legacy_alignment_surface_pending_avm",
|
||||||
|
"computed_in_shim": [
|
||||||
|
"determine_alignment",
|
||||||
|
"rewrite_warnings",
|
||||||
|
"alignment_counts",
|
||||||
|
"warning_counts",
|
||||||
|
"receipt_hash recomputation",
|
||||||
|
],
|
||||||
|
"must_port_to_lean_avm": [
|
||||||
|
"determine_alignment",
|
||||||
|
"rewrite_warnings",
|
||||||
|
"update_hash canonical payload definition",
|
||||||
|
],
|
||||||
|
"float_policy": "no float used in this shim",
|
||||||
|
}
|
||||||
|
out["records"] = aligned_records
|
||||||
|
out["shape_alignment_claim_boundary"] = {
|
||||||
|
"means": "PIST structural morphology and RRC semantic routing labels have been calibrated (legacy shim surface)",
|
||||||
|
"does_not_mean": "mathematical proof or claim promotion",
|
||||||
|
"promotion_policy": "not_promoted for every record",
|
||||||
|
}
|
||||||
|
return out, raw_warning_counts
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Align RRC semantic labels with PIST structural labels.")
|
||||||
|
parser.add_argument("--input", type=Path, default=DEFAULT_IN)
|
||||||
|
parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
|
||||||
|
parser.add_argument(
|
||||||
|
"--fail-on-raw-disagreement",
|
||||||
|
action="store_true",
|
||||||
|
help="Fail if the *input* JSON still contains any raw 'pist_shape_disagreement' warnings.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
payload = json.loads(args.input.read_text(encoding="utf-8"))
|
||||||
|
aligned, raw_warning_counts = align_payload(payload)
|
||||||
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.out.write_text(json.dumps(aligned, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
summary = aligned["summary"]
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"shape_alignment_counts": summary.get("shape_alignment_counts"),
|
||||||
|
"warning_counts": summary.get("warning_counts"),
|
||||||
|
"raw_warning_counts": summary.get("raw_warning_counts"),
|
||||||
|
"promotion_policy": summary.get("promotion_policy"),
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print(f"Wrote aligned receipt-density JSON: {args.out}")
|
||||||
|
|
||||||
|
if args.fail_on_raw_disagreement and raw_warning_counts.get("pist_shape_disagreement", 0):
|
||||||
|
return 2
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
178
4-Infrastructure/shim/test_pist_receipt_density_injector.py
Normal file
178
4-Infrastructure/shim/test_pist_receipt_density_injector.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression tests for pist_receipt_density_injector.py.
|
||||||
|
|
||||||
|
These tests are intentionally dependency-light and can run with plain Python:
|
||||||
|
|
||||||
|
python3 4-Infrastructure/shim/test_pist_receipt_density_injector.py
|
||||||
|
|
||||||
|
They protect the Phase 2.1 anti-drift boundaries:
|
||||||
|
|
||||||
|
* Markdown table noise is not treated as equations.
|
||||||
|
* Receipt density and confidence are bounded.
|
||||||
|
* Every generated record is explicitly not promoted.
|
||||||
|
* Unsafe RDS table identifiers are rejected before SQL construction.
|
||||||
|
* RDS writes remain opt-in; default runs produce JSON only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pist_receipt_density_injector as inj
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE_RRC = """# RRC Equation Projection
|
||||||
|
|
||||||
|
## Sample Projections
|
||||||
|
|
||||||
|
| Equation | RRC shape | Status | Top axes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bandwidth_adjusted_threshold` | `CognitiveLoadField` | `CANDIDATE` | `projection_declared, negative_control_strength, witness_declared, scale_band_declared` |
|
||||||
|
| `source_domain` | `SignalShapedRouteCompiler` | `CANDIDATE` | `projection_declared, shape_closure, witness_declared, scale_band_declared` |
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE_PIST = {
|
||||||
|
"predictions": [
|
||||||
|
{
|
||||||
|
"equation": "Equation",
|
||||||
|
"ground_truth": "RRC shape",
|
||||||
|
"proxy_pred": "LogogramProjection",
|
||||||
|
"exact_pred": "LogogramProjection",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"equation": "---",
|
||||||
|
"ground_truth": "---",
|
||||||
|
"proxy_pred": "LogogramProjection",
|
||||||
|
"exact_pred": "LogogramProjection",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"equation": "bandwidth_adjusted_threshold",
|
||||||
|
"ground_truth": "CognitiveLoadField",
|
||||||
|
"proxy_pred": "CognitiveLoadField",
|
||||||
|
"exact_pred": "CognitiveLoadField",
|
||||||
|
"matrix_hash": "mhash001",
|
||||||
|
"canonical_hash": "chash001",
|
||||||
|
"spectral_gap": 0.5,
|
||||||
|
"rank_estimate": 8,
|
||||||
|
"laplacian_zero_count": 1,
|
||||||
|
"crossing_density": 0.25,
|
||||||
|
"strand_entropy": 3.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"equation": "source_domain",
|
||||||
|
"ground_truth": "SignalShapedRouteCompiler",
|
||||||
|
"proxy_pred": "LogogramProjection",
|
||||||
|
"exact_pred": "LogogramProjection",
|
||||||
|
"matrix_hash": "mhash002",
|
||||||
|
"canonical_hash": "chash002",
|
||||||
|
"spectral_gap": 0.25,
|
||||||
|
"rank_estimate": 7,
|
||||||
|
"laplacian_zero_count": 1,
|
||||||
|
"crossing_density": 0.125,
|
||||||
|
"strand_entropy": 2.5,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assert_true(condition: bool, message: str) -> None:
|
||||||
|
if not condition:
|
||||||
|
raise AssertionError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_noise_filtering() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
path = Path(td) / "rrc.md"
|
||||||
|
path.write_text(SAMPLE_RRC, encoding="utf-8")
|
||||||
|
rows = inj.parse_rrc_table(path)
|
||||||
|
assert_true(len(rows) == 2, f"expected 2 real rows, got {len(rows)}")
|
||||||
|
assert_true(all(r.equation_id not in {"Equation", "---"} for r in rows), "table noise leaked into rows")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pist_noise_filtering() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
path = Path(td) / "pist.json"
|
||||||
|
path.write_text(json.dumps(SAMPLE_PIST), encoding="utf-8")
|
||||||
|
preds = inj.load_pist_predictions(path)
|
||||||
|
assert_true(set(preds) == {"bandwidth_adjusted_threshold", "source_domain"}, f"unexpected predictions: {set(preds)}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_bounds_and_no_promotion() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
rrc = Path(td) / "rrc.md"
|
||||||
|
pist = Path(td) / "pist.json"
|
||||||
|
rrc.write_text(SAMPLE_RRC, encoding="utf-8")
|
||||||
|
pist.write_text(json.dumps(SAMPLE_PIST), encoding="utf-8")
|
||||||
|
rows = inj.parse_rrc_table(rrc)
|
||||||
|
preds = inj.load_pist_predictions(pist)
|
||||||
|
records = [inj.build_record(row, preds.get(row.equation_id)) for row in rows]
|
||||||
|
assert_true(len(records) == 2, "expected 2 records")
|
||||||
|
for record in records:
|
||||||
|
assert_true(0.0 <= record.receipt_density <= 1.0, f"density out of range: {record}")
|
||||||
|
assert_true(0.0 <= record.confidence <= 1.0, f"confidence out of range: {record}")
|
||||||
|
assert_true(record.promotion == "not_promoted", "promotion boundary violated")
|
||||||
|
assert_true(record.receipt_hash, "missing receipt hash")
|
||||||
|
|
||||||
|
|
||||||
|
def test_shape_disagreement_warning() -> None:
|
||||||
|
row = inj.RRCEquationRow(
|
||||||
|
equation_id="source_domain",
|
||||||
|
rrc_shape="SignalShapedRouteCompiler",
|
||||||
|
status="CANDIDATE",
|
||||||
|
top_axes=["projection_declared", "shape_closure"],
|
||||||
|
)
|
||||||
|
pred = SAMPLE_PIST["predictions"][-1]
|
||||||
|
record = inj.build_record(row, pred)
|
||||||
|
assert_true("pist_shape_disagreement" in record.warnings, "expected disagreement warning")
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_identifier_validation() -> None:
|
||||||
|
assert_true(inj.split_qualified_table("ene.rrc_receipt_density") == ("ene", "rrc_receipt_density"), "valid table rejected")
|
||||||
|
bad_tables = [
|
||||||
|
"ene.bad;drop table x",
|
||||||
|
"ene.rrc receipt density",
|
||||||
|
"ene..rrc_receipt_density",
|
||||||
|
"ene.rrc_receipt_density where 1=1",
|
||||||
|
]
|
||||||
|
for table in bad_tables:
|
||||||
|
try:
|
||||||
|
inj.split_qualified_table(table)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError(f"unsafe table accepted: {table}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_main_does_not_write_rds() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
rrc = Path(td) / "rrc.md"
|
||||||
|
pist = Path(td) / "pist.json"
|
||||||
|
out = Path(td) / "out.json"
|
||||||
|
rrc.write_text(SAMPLE_RRC, encoding="utf-8")
|
||||||
|
pist.write_text(json.dumps(SAMPLE_PIST), encoding="utf-8")
|
||||||
|
code = inj.main(["--rrc-file", str(rrc), "--pist-report", str(pist), "--out", str(out)])
|
||||||
|
assert_true(code == 0, f"main returned {code}")
|
||||||
|
payload = json.loads(out.read_text(encoding="utf-8"))
|
||||||
|
assert_true(payload["summary"]["rds_write"]["enabled"] is False, "RDS write should be disabled by default")
|
||||||
|
assert_true(payload["summary"]["records"] == 2, "unexpected record count")
|
||||||
|
|
||||||
|
|
||||||
|
def run_all() -> None:
|
||||||
|
tests = [
|
||||||
|
test_table_noise_filtering,
|
||||||
|
test_pist_noise_filtering,
|
||||||
|
test_record_bounds_and_no_promotion,
|
||||||
|
test_shape_disagreement_warning,
|
||||||
|
test_table_identifier_validation,
|
||||||
|
test_default_main_does_not_write_rds,
|
||||||
|
]
|
||||||
|
for test in tests:
|
||||||
|
test()
|
||||||
|
print(f"PASS {test.__name__}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_all()
|
||||||
151
4-Infrastructure/shim/validate_receipt_density_sidecar.py
Normal file
151
4-Infrastructure/shim/validate_receipt_density_sidecar.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate the RRC receipt-density sidecar table.
|
||||||
|
|
||||||
|
Readback validator for Phase 2.1. Uses the shared rds_connect.connect_rds helper
|
||||||
|
and checks the anti-drift boundary after a guarded --write-rds run.
|
||||||
|
|
||||||
|
Default table: ene.rrc_receipt_density
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
SHIM_DIR = Path(__file__).resolve().parent
|
||||||
|
if str(SHIM_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SHIM_DIR))
|
||||||
|
|
||||||
|
DEFAULT_RDS_TABLE = "ene.rrc_receipt_density"
|
||||||
|
_ALLOWED_TABLES = {"ene.rrc_receipt_density", "public.rrc_receipt_density"}
|
||||||
|
|
||||||
|
|
||||||
|
def table_name(table: str) -> str:
|
||||||
|
"""Return a known-safe qualified table name.
|
||||||
|
|
||||||
|
This validator intentionally accepts only known sidecar table names. The writer
|
||||||
|
has a generic identifier validator; the readback validator is stricter because
|
||||||
|
it should only verify the receipt-density sidecar.
|
||||||
|
"""
|
||||||
|
if table not in _ALLOWED_TABLES:
|
||||||
|
raise ValueError(f"table not allowed for this validator: {table!r}")
|
||||||
|
schema, name = table.split(".", 1)
|
||||||
|
for ident in (schema, name):
|
||||||
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", ident):
|
||||||
|
raise ValueError(f"unsafe identifier: {ident!r}")
|
||||||
|
return f'"{schema}"."{name}"'
|
||||||
|
|
||||||
|
|
||||||
|
def connect(connect_timeout: int | None):
|
||||||
|
try:
|
||||||
|
from rds_connect import connect_rds
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("rds_connect.py is required for sidecar validation") from exc
|
||||||
|
overrides: dict[str, Any] = {}
|
||||||
|
if connect_timeout is not None:
|
||||||
|
overrides["connect_timeout"] = connect_timeout
|
||||||
|
return connect_rds(**overrides)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_table(conn: Any, table: str) -> dict[str, Any]:
|
||||||
|
full = table_name(table)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN promotion != 'not_promoted' THEN 1 ELSE 0 END) AS promoted_rows,
|
||||||
|
SUM(CASE WHEN receipt_density IS NULL THEN 1 ELSE 0 END) AS null_density_rows,
|
||||||
|
SUM(CASE WHEN receipt_density < 0 OR receipt_density > 1 THEN 1 ELSE 0 END) AS out_of_range_density_rows,
|
||||||
|
SUM(CASE WHEN confidence IS NULL THEN 1 ELSE 0 END) AS null_confidence_rows,
|
||||||
|
SUM(CASE WHEN confidence < 0 OR confidence > 1 THEN 1 ELSE 0 END) AS out_of_range_confidence_rows,
|
||||||
|
SUM(CASE WHEN receipt_density_hash IS NULL OR receipt_density_hash = '' THEN 1 ELSE 0 END) AS missing_hash_rows,
|
||||||
|
SUM(CASE WHEN receipt_density_source IS NULL OR receipt_density_source = '' THEN 1 ELSE 0 END) AS missing_source_rows,
|
||||||
|
SUM(CASE WHEN receipt_density_status NOT IN ('CANDIDATE', 'HOLD') THEN 1 ELSE 0 END) AS bad_status_rows
|
||||||
|
FROM {full}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
keys = [
|
||||||
|
"total",
|
||||||
|
"promoted_rows",
|
||||||
|
"null_density_rows",
|
||||||
|
"out_of_range_density_rows",
|
||||||
|
"null_confidence_rows",
|
||||||
|
"out_of_range_confidence_rows",
|
||||||
|
"missing_hash_rows",
|
||||||
|
"missing_source_rows",
|
||||||
|
"bad_status_rows",
|
||||||
|
]
|
||||||
|
summary = {k: int(v or 0) for k, v in zip(keys, row)}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT rrc_shape, COUNT(*), AVG(receipt_density)
|
||||||
|
FROM {full}
|
||||||
|
GROUP BY rrc_shape
|
||||||
|
ORDER BY rrc_shape
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
by_shape = [
|
||||||
|
{
|
||||||
|
"rrc_shape": shape,
|
||||||
|
"count": int(count),
|
||||||
|
"mean_receipt_density": round(float(avg), 6) if avg is not None else None,
|
||||||
|
}
|
||||||
|
for shape, count, avg in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
if summary["total"] <= 0:
|
||||||
|
errors.append("sidecar_table_empty")
|
||||||
|
for key in [
|
||||||
|
"promoted_rows",
|
||||||
|
"null_density_rows",
|
||||||
|
"out_of_range_density_rows",
|
||||||
|
"null_confidence_rows",
|
||||||
|
"out_of_range_confidence_rows",
|
||||||
|
"missing_hash_rows",
|
||||||
|
"missing_source_rows",
|
||||||
|
"bad_status_rows",
|
||||||
|
]:
|
||||||
|
if summary[key] != 0:
|
||||||
|
errors.append(key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"table": table,
|
||||||
|
"valid": len(errors) == 0,
|
||||||
|
"errors": errors,
|
||||||
|
"summary": summary,
|
||||||
|
"by_shape": by_shape,
|
||||||
|
"promotion_policy": "all rows must remain not_promoted",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Validate the RRC receipt-density sidecar table.")
|
||||||
|
parser.add_argument("--rds-table", default=DEFAULT_RDS_TABLE, choices=sorted(_ALLOWED_TABLES))
|
||||||
|
parser.add_argument("--connect-timeout", type=int, default=10)
|
||||||
|
parser.add_argument("--out", type=Path, default=None, help="Optional JSON report output path.")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
conn = connect(args.connect_timeout)
|
||||||
|
try:
|
||||||
|
result = validate_table(conn, args.rds_table)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(json.dumps(result, indent=2, sort_keys=True))
|
||||||
|
if args.out is not None:
|
||||||
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.out.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
return 0 if result["valid"] else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -1,361 +1,112 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""deploy_ene_full_mesh.py — Deploy ENE to Full Tailscale Mesh (legacy shim).
|
||||||
deploy_ene_full_mesh.py — Deploy ENE to Full Tailscale Mesh
|
|
||||||
|
|
||||||
Uses ENE's self-replication capability to:
|
Cleanups:
|
||||||
1. Deploy to remaining 3 nodes (ip-172-31-25-81, netcup-router, racknerd-510bd9c)
|
- Removed hardcoded node lists; reads nodes from 4-Infrastructure/auto/config/nodes.yaml.
|
||||||
2. Enable full mesh monitoring
|
|
||||||
3. Start distributed load balancing across all 6 nodes
|
NOTE:
|
||||||
4. Begin utilizing idle capacity (36 cores, 72GB RAM)
|
- This script still references deprecated Python ENE controller surfaces.
|
||||||
|
- Treat as an orchestration stub pending the Rust ENE crate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Any
|
from typing import Any, Dict
|
||||||
|
|
||||||
# Import ENE infrastructure
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "4-Infrastructure" / "infra"))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "4-Infrastructure" / "infra"))
|
||||||
|
|
||||||
# DEPRECATED: Python ENE is replaced by Rust (1-Distributed-Systems/ene/src/).
|
|
||||||
# Use the Rust crate instead: `cargo run --manifest-path 1-Distributed-Systems/ene/Cargo.toml`
|
|
||||||
try:
|
try:
|
||||||
from ene_distributed_node import ENEMeshController, ENEDistributedNode # type: ignore
|
from ene_distributed_node import ENEMeshController # type: ignore
|
||||||
except ImportError:
|
except ImportError:
|
||||||
ENEMeshController = None
|
ENEMeshController = None
|
||||||
ENEDistributedNode = None
|
|
||||||
from ene_cloud_credential_manager import ENETopologicalStorage
|
|
||||||
|
def _load_nodes_inventory(path: Path) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
import yaml # type: ignore
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("nodes.yaml parsing requires PyYAML (pip install pyyaml)") from exc
|
||||||
|
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(data, dict) or "nodes" not in data:
|
||||||
|
raise ValueError(f"Invalid nodes inventory file: {path}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class FullMeshDeployment:
|
class FullMeshDeployment:
|
||||||
"""Deploy ENE across full Tailscale mesh and activate distributed workloads."""
|
def __init__(self, inventory_path: Path):
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
if ENEMeshController is None:
|
if ENEMeshController is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Python ENE mesh controller is removed; use the Rust ENE crate under "
|
"Python ENE mesh controller is removed; use the Rust ENE crate under 1-Distributed-Systems/ene instead."
|
||||||
"1-Distributed-Systems/ene instead."
|
|
||||||
)
|
)
|
||||||
|
self.inventory_path = inventory_path
|
||||||
|
self.inventory = _load_nodes_inventory(inventory_path)
|
||||||
self.controller = ENEMeshController()
|
self.controller = ENEMeshController()
|
||||||
self.mesh_nodes: Dict[str, Any] = {}
|
self.mesh_nodes: Dict[str, Any] = {}
|
||||||
self.target_nodes = [
|
|
||||||
"ip-172-31-25-81", # AWS node
|
def step1_spawn_inventory_nodes(self) -> Dict[str, Any]:
|
||||||
"netcup-router", # Netcup VPS
|
print("\n[STEP 1] Spawning ENE nodes from inventory...")
|
||||||
"racknerd-510bd9c" # Racknerd VPS
|
nodes = self.inventory.get("nodes", {})
|
||||||
]
|
spawned = 0
|
||||||
|
|
||||||
def step1_spawn_existing_nodes(self) -> Dict[str, Any]:
|
for node_id in sorted(nodes.keys()):
|
||||||
"""Step 1: Spawn ENE on existing nodes (qfox, architect, judge)."""
|
node = self.controller.spawn_node(f"ene_{node_id}")
|
||||||
print("\n[STEP 1] Spawning ENE on existing nodes...")
|
self.mesh_nodes[node_id] = {"node": node, "status": "active"}
|
||||||
|
spawned += 1
|
||||||
existing = {
|
print(f" ✅ {node_id}")
|
||||||
"qfox": {"cpu": 16, "ram": 32, "storage": 1000, "gpu": 1},
|
|
||||||
"architect": {"cpu": 8, "ram": 16, "storage": 500, "gpu": 0},
|
return {"step": 1, "nodes_spawned": spawned}
|
||||||
"judge": {"cpu": 4, "ram": 8, "storage": 200, "gpu": 0}
|
|
||||||
}
|
def step2_auto_replicate(self) -> Dict[str, Any]:
|
||||||
|
print("\n[STEP 2] Auto-replication (stub)...")
|
||||||
for hostname, specs in existing.items():
|
if not self.mesh_nodes:
|
||||||
node = self.controller.spawn_node(f"ene_{hostname}")
|
return {"step": 2, "error": "no nodes spawned"}
|
||||||
self.mesh_nodes[hostname] = {
|
|
||||||
"node": node,
|
|
||||||
"specs": specs,
|
|
||||||
"status": "active"
|
|
||||||
}
|
|
||||||
print(f" ✅ {hostname}: {specs['cpu']} cores, {specs['ram']}GB RAM")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"step": 1,
|
|
||||||
"nodes_spawned": len(existing),
|
|
||||||
"total_cores": sum(n["specs"]["cpu"] for n in self.mesh_nodes.values()),
|
|
||||||
"total_ram": sum(n["specs"]["ram"] for n in self.mesh_nodes.values())
|
|
||||||
}
|
|
||||||
|
|
||||||
def step2_deploy_to_new_nodes(self) -> Dict[str, Any]:
|
|
||||||
"""Step 2: Auto-replicate ENE to remaining 3 nodes."""
|
|
||||||
print("\n[STEP 2] Deploying ENE to remaining nodes via auto-replication...")
|
|
||||||
|
|
||||||
# Get first node to act as replication source
|
|
||||||
source_node = list(self.mesh_nodes.values())[0]["node"]
|
source_node = list(self.mesh_nodes.values())[0]["node"]
|
||||||
|
|
||||||
deployed = []
|
deployed = []
|
||||||
failed = []
|
failed = []
|
||||||
|
for node_id, data in self.mesh_nodes.items():
|
||||||
new_specs = {
|
remote = data["node"]
|
||||||
"ip-172-31-25-81": {"cpu": 2, "ram": 4, "storage": 100, "gpu": 0},
|
|
||||||
"netcup-router": {"cpu": 4, "ram": 8, "storage": 500, "gpu": 0},
|
|
||||||
"racknerd-510bd9c": {"cpu": 2, "ram": 4, "storage": 100, "gpu": 0}
|
|
||||||
}
|
|
||||||
|
|
||||||
for hostname in self.target_nodes:
|
|
||||||
print(f"\n Deploying to {hostname}...")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Simulate SSH/remote deployment
|
time.sleep(0.1)
|
||||||
# In production, this would:
|
remote.auto_replicate([source_node.node_id])
|
||||||
# 1. SSH to remote node
|
deployed.append(node_id)
|
||||||
# 2. Copy ENE binary
|
|
||||||
# 3. Start ENE service
|
|
||||||
# 4. Join mesh
|
|
||||||
|
|
||||||
# Simulate replication
|
|
||||||
time.sleep(0.5) # Replication time
|
|
||||||
|
|
||||||
# Spawn remote node in controller
|
|
||||||
remote_node = self.controller.spawn_node(f"ene_{hostname}")
|
|
||||||
|
|
||||||
# Trigger auto-replication from source
|
|
||||||
remote_node.auto_replicate([source_node.node_id])
|
|
||||||
|
|
||||||
self.mesh_nodes[hostname] = {
|
|
||||||
"node": remote_node,
|
|
||||||
"specs": new_specs[hostname],
|
|
||||||
"status": "active"
|
|
||||||
}
|
|
||||||
|
|
||||||
deployed.append(hostname)
|
|
||||||
print(f" ✅ Deployed: {new_specs[hostname]['cpu']} cores, {new_specs[hostname]['ram']}GB RAM")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
failed.append((hostname, str(e)))
|
failed.append((node_id, str(e)))
|
||||||
print(f" ❌ Failed: {e}")
|
|
||||||
|
return {"step": 2, "deployed": deployed, "failed": failed}
|
||||||
return {
|
|
||||||
"step": 2,
|
|
||||||
"deployed": deployed,
|
|
||||||
"failed": failed,
|
|
||||||
"deployment_rate": len(deployed) / len(self.target_nodes) * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
def step3_enable_gossip_mesh(self) -> Dict[str, Any]:
|
|
||||||
"""Step 3: Enable gossip protocol across full mesh."""
|
|
||||||
print("\n[STEP 3] Enabling gossip protocol across 6-node mesh...")
|
|
||||||
|
|
||||||
gossip_count = 0
|
|
||||||
|
|
||||||
for hostname, data in self.mesh_nodes.items():
|
|
||||||
node = data["node"]
|
|
||||||
|
|
||||||
# Create discovery gossip
|
|
||||||
gossip = node.create_gossip("discovery", {
|
|
||||||
"node_id": node.node_id,
|
|
||||||
"hostname": hostname,
|
|
||||||
"resources": data["specs"],
|
|
||||||
"capabilities": ["storage", "compute", "relay"]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Broadcast to mesh
|
|
||||||
node.gossip_to_peers(gossip)
|
|
||||||
gossip_count += 1
|
|
||||||
|
|
||||||
print(f" 📡 {hostname}: gossip broadcast")
|
|
||||||
|
|
||||||
# Calculate mesh health
|
|
||||||
total_nodes = len(self.mesh_nodes)
|
|
||||||
healthy_nodes = sum(1 for n in self.mesh_nodes.values() if n["status"] == "active")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"step": 3,
|
|
||||||
"gossip_messages": gossip_count,
|
|
||||||
"mesh_size": total_nodes,
|
|
||||||
"healthy_nodes": healthy_nodes,
|
|
||||||
"mesh_status": "healthy" if healthy_nodes == total_nodes else "degraded"
|
|
||||||
}
|
|
||||||
|
|
||||||
def step4_distribute_credentials(self) -> Dict[str, Any]:
|
|
||||||
"""Step 4: Distribute Google Drive credentials to all nodes."""
|
|
||||||
print("\n[STEP 4] Distributing GDrive credentials to all 6 nodes...")
|
|
||||||
|
|
||||||
# Get first node's credential manager
|
|
||||||
first_node = list(self.mesh_nodes.values())[0]["node"]
|
|
||||||
|
|
||||||
# Store credential in first node
|
|
||||||
# (This would normally be done via the ENE API)
|
|
||||||
|
|
||||||
# Distribute to other nodes via gossip
|
|
||||||
cred_gossip = first_node.create_gossip("credential_sync", {
|
|
||||||
"credential_id": "cred_gdrive_mesh",
|
|
||||||
"provider": "gdrive",
|
|
||||||
"fragment_shards": 6, # One shard per node
|
|
||||||
"access_level": "RESTRICTED"
|
|
||||||
})
|
|
||||||
|
|
||||||
first_node.gossip_to_peers(cred_gossip)
|
|
||||||
|
|
||||||
print(f" 🔐 Credential distributed to {len(self.mesh_nodes)} nodes")
|
|
||||||
print(f" 🔐 Shamir shards: 6 (one per node)")
|
|
||||||
print(f" 🔐 Consensus required for rotation")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"step": 4,
|
|
||||||
"credential_shards": len(self.mesh_nodes),
|
|
||||||
"consensus_threshold": "2/3 majority",
|
|
||||||
"distribution": "shamir-secret-sharing"
|
|
||||||
}
|
|
||||||
|
|
||||||
def step5_activate_load_balancing(self) -> Dict[str, Any]:
|
|
||||||
"""Step 5: Activate distributed load balancing."""
|
|
||||||
print("\n[STEP 5] Activating distributed load balancing...")
|
|
||||||
|
|
||||||
# Create ENE topological storage interface
|
|
||||||
ene_storage = ENETopologicalStorage()
|
|
||||||
|
|
||||||
# Register all 6 nodes with load balancer
|
|
||||||
for hostname, data in self.mesh_nodes.items():
|
|
||||||
node_id = f"ene_{hostname}"
|
|
||||||
ene_storage.balancer.register_node(node_id, "cred_gdrive_mesh")
|
|
||||||
print(f" ⚖️ {hostname} registered for load balancing")
|
|
||||||
|
|
||||||
# Get balancer stats
|
|
||||||
stats = ene_storage.balancer.get_balancer_stats()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"step": 5,
|
|
||||||
"nodes_registered": len(self.mesh_nodes),
|
|
||||||
"balancing_strategy": "health_weighted",
|
|
||||||
"total_gpus": sum(n["specs"]["gpu"] for n in self.mesh_nodes.values()),
|
|
||||||
"storage": ene_storage.get_storage_health()
|
|
||||||
}
|
|
||||||
|
|
||||||
def step6_launch_distributed_waveprobes(self) -> Dict[str, Any]:
|
|
||||||
"""Step 6: Launch waveprobes across full mesh to test capacity."""
|
|
||||||
print("\n[STEP 6] Launching distributed waveprobes across mesh...")
|
|
||||||
|
|
||||||
ene_storage = ENETopologicalStorage()
|
|
||||||
|
|
||||||
# Launch 6 waveprobes (one targeting each node)
|
|
||||||
waveprobes = []
|
|
||||||
latencies = []
|
|
||||||
|
|
||||||
for i, (hostname, data) in enumerate(self.mesh_nodes.items()):
|
|
||||||
# Create waveprobe
|
|
||||||
probe_id = f"wave_mesh_{i+1}_{hostname}"
|
|
||||||
|
|
||||||
# Simulate upload via ENE (which selects best node)
|
|
||||||
start = time.time()
|
|
||||||
|
|
||||||
# ENE automatically selects node based on health
|
|
||||||
result = {
|
|
||||||
"probe_id": probe_id,
|
|
||||||
"target_node": hostname,
|
|
||||||
"bytes": 407,
|
|
||||||
"duration_ms": 0,
|
|
||||||
"selected_by_ene": True
|
|
||||||
}
|
|
||||||
|
|
||||||
# Simulate latency (would be real in production)
|
|
||||||
import random
|
|
||||||
latency = random.uniform(50, 200)
|
|
||||||
time.sleep(latency / 1000)
|
|
||||||
|
|
||||||
result["duration_ms"] = latency
|
|
||||||
latencies.append(latency)
|
|
||||||
waveprobes.append(result)
|
|
||||||
|
|
||||||
print(f" 📤 {probe_id} → {hostname}: {latency:.1f}ms")
|
|
||||||
|
|
||||||
avg_latency = sum(latencies) / len(latencies) if latencies else 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"step": 6,
|
|
||||||
"waveprobes_launched": len(waveprobes),
|
|
||||||
"avg_latency_ms": avg_latency,
|
|
||||||
"max_latency_ms": max(latencies) if latencies else 0,
|
|
||||||
"min_latency_ms": min(latencies) if latencies else 0,
|
|
||||||
"distributed": True
|
|
||||||
}
|
|
||||||
|
|
||||||
def step7_full_capacity_report(self) -> Dict[str, Any]:
|
|
||||||
"""Step 7: Report on full mesh capacity utilization."""
|
|
||||||
print("\n[STEP 7] Full mesh capacity report...")
|
|
||||||
|
|
||||||
total_cores = sum(n["specs"]["cpu"] for n in self.mesh_nodes.values())
|
|
||||||
total_ram = sum(n["specs"]["ram"] for n in self.mesh_nodes.values())
|
|
||||||
total_storage = sum(n["specs"]["storage"] for n in self.mesh_nodes.values())
|
|
||||||
total_gpu = sum(n["specs"]["gpu"] for n in self.mesh_nodes.values())
|
|
||||||
|
|
||||||
print(f" 🖥️ Total Cores: {total_cores}")
|
|
||||||
print(f" 🧠 Total RAM: {total_ram} GB")
|
|
||||||
print(f" 💾 Total Storage: {total_storage} GB")
|
|
||||||
print(f" 🎮 Total GPUs: {total_gpu}")
|
|
||||||
print(f" 🌐 Mesh Size: {len(self.mesh_nodes)} nodes")
|
|
||||||
print(f" 🔗 ENE Coverage: 100%")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"step": 7,
|
|
||||||
"total_cores": total_cores,
|
|
||||||
"total_ram_gb": total_ram,
|
|
||||||
"total_storage_gb": total_storage,
|
|
||||||
"total_gpus": total_gpu,
|
|
||||||
"ene_coverage_percent": 100,
|
|
||||||
"mesh_fully_utilized": True
|
|
||||||
}
|
|
||||||
|
|
||||||
def deploy_full_mesh(self) -> Dict[str, Any]:
|
def deploy_full_mesh(self) -> Dict[str, Any]:
|
||||||
"""Execute full mesh deployment."""
|
results: Dict[str, Any] = {}
|
||||||
print("=" * 70)
|
results["step1"] = self.step1_spawn_inventory_nodes()
|
||||||
print("ENE FULL MESH DEPLOYMENT")
|
results["step2"] = self.step2_auto_replicate()
|
||||||
print("Target: 6 nodes, 36 cores, 72GB RAM")
|
results["inventory"] = str(self.inventory_path)
|
||||||
print("=" * 70)
|
results["mesh_size"] = len(self.mesh_nodes)
|
||||||
|
results["status"] = "operational" if self.mesh_nodes else "failed"
|
||||||
results = {}
|
return results
|
||||||
|
|
||||||
# Execute all steps
|
|
||||||
results["step1"] = self.step1_spawn_existing_nodes()
|
|
||||||
results["step2"] = self.step2_deploy_to_new_nodes()
|
|
||||||
results["step3"] = self.step3_enable_gossip_mesh()
|
|
||||||
results["step4"] = self.step4_distribute_credentials()
|
|
||||||
results["step5"] = self.step5_activate_load_balancing()
|
|
||||||
results["step6"] = self.step6_launch_distributed_waveprobes()
|
|
||||||
results["step7"] = self.step7_full_capacity_report()
|
|
||||||
|
|
||||||
# Final report
|
|
||||||
final = {
|
|
||||||
"deployment": "complete",
|
|
||||||
"mesh_size": len(self.mesh_nodes),
|
|
||||||
"ene_coverage": "100%",
|
|
||||||
"resources": {
|
|
||||||
"cpu_cores": results["step7"]["total_cores"],
|
|
||||||
"memory_gb": results["step7"]["total_ram_gb"],
|
|
||||||
"storage_gb": results["step7"]["total_storage_gb"],
|
|
||||||
"gpus": results["step7"]["total_gpus"]
|
|
||||||
},
|
|
||||||
"features": [
|
|
||||||
"Auto-replication to new nodes",
|
|
||||||
"Gossip protocol enabled",
|
|
||||||
"Shamir-secret credential distribution",
|
|
||||||
"Health-weighted load balancing",
|
|
||||||
"Distributed waveprobe execution"
|
|
||||||
],
|
|
||||||
"status": "operational"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Save report
|
|
||||||
output_path = Path("/home/allaun/Documents/Research Stack/data/ene_full_mesh_deployment.json")
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(output_path, "w") as f:
|
|
||||||
json.dump(final, f, indent=2)
|
|
||||||
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print("DEPLOYMENT COMPLETE")
|
|
||||||
print("=" * 70)
|
|
||||||
print(f"Mesh: {final['mesh_size']} nodes")
|
|
||||||
print(f"ENE: {final['ene_coverage']}")
|
|
||||||
print(f"Resources: {final['resources']['cpu_cores']} cores, {final['resources']['memory_gb']}GB RAM")
|
|
||||||
print(f"Status: {final['status']}")
|
|
||||||
print(f"Output: {output_path}")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
return final
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main(argv: list[str] | None = None) -> int:
|
||||||
"""Run full mesh deployment."""
|
parser = argparse.ArgumentParser()
|
||||||
deployment = FullMeshDeployment()
|
parser.add_argument(
|
||||||
|
"--inventory",
|
||||||
|
type=Path,
|
||||||
|
default=Path("4-Infrastructure/auto/config/nodes.yaml"),
|
||||||
|
help="Path to nodes.yaml",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
deployment = FullMeshDeployment(args.inventory)
|
||||||
result = deployment.deploy_full_mesh()
|
result = deployment.deploy_full_mesh()
|
||||||
return result
|
print(json.dumps(result, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
raise SystemExit(main())
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,44 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""Markdown to JSON-L Converter (legacy shim).
|
||||||
Markdown to JSON-L Converter
|
|
||||||
|
|
||||||
Converts all unconverted .md files in the workspace to JSON-L format
|
Converts .md files in a local workspace to JSON-L format compatible with
|
||||||
compatible with UNIFIED_JSONL_SCHEMA.md using src="ene".
|
UNIFIED_JSONL_SCHEMA.md.
|
||||||
|
|
||||||
Usage:
|
Hardcoded node/provenance assumptions were removed:
|
||||||
python md_to_jsonl_converter.py
|
- node id is now configurable via --node-id or ENE_NODE_ID
|
||||||
python md_to_jsonl_converter.py --output-file <path>
|
- tailscale ip is now configurable via ENE_TAILSCALE_IP
|
||||||
|
|
||||||
|
NOTE: This is a legacy ingest surface and should be treated as non-authoritative.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import hashlib
|
import hashlib
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
|
|
||||||
# Configuration
|
|
||||||
WORKSPACE_ROOT = Path("/home/allaun/Documents/Research Stack")
|
def env_default(name: str, default: str) -> str:
|
||||||
|
try:
|
||||||
|
v = __import__("os").environ.get(name)
|
||||||
|
except Exception:
|
||||||
|
v = None
|
||||||
|
return v if v is not None and v != "" else default
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration (still workspace-local; TODO: make this configurable via args)
|
||||||
|
WORKSPACE_ROOT = Path(env_default("ENE_WORKSPACE_ROOT", "/home/allaun/Documents/Research Stack"))
|
||||||
MANIFEST_PATH = WORKSPACE_ROOT / "data" / "manifest.jsonl"
|
MANIFEST_PATH = WORKSPACE_ROOT / "data" / "manifest.jsonl"
|
||||||
SEARCH_DIRS = [
|
SEARCH_DIRS = [
|
||||||
WORKSPACE_ROOT, # Root .md files
|
WORKSPACE_ROOT,
|
||||||
WORKSPACE_ROOT / "docs",
|
WORKSPACE_ROOT / "docs",
|
||||||
WORKSPACE_ROOT / "docs" / "semantics",
|
WORKSPACE_ROOT / "docs" / "semantics",
|
||||||
WORKSPACE_ROOT / "data" / "germane" / "research",
|
WORKSPACE_ROOT / "data" / "germane" / "research",
|
||||||
WORKSPACE_ROOT / "data" / "germane" / "architecture",
|
WORKSPACE_ROOT / "data" / "germane" / "architecture",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Domain classification by filename pattern
|
|
||||||
DOMAIN_PATTERNS = {
|
DOMAIN_PATTERNS = {
|
||||||
"LEAn|lean|semantics": "formalization",
|
"LEAn|lean|semantics": "formalization",
|
||||||
"MATH|math|equation": "mathematics",
|
"MATH|math|equation": "mathematics",
|
||||||
|
|
@ -53,7 +61,6 @@ TIER_MAPPING = {
|
||||||
|
|
||||||
|
|
||||||
def compute_sha256(filepath: Path) -> str:
|
def compute_sha256(filepath: Path) -> str:
|
||||||
"""Compute SHA256 hash of file."""
|
|
||||||
sha256 = hashlib.sha256()
|
sha256 = hashlib.sha256()
|
||||||
with open(filepath, "rb") as f:
|
with open(filepath, "rb") as f:
|
||||||
for chunk in iter(lambda: f.read(8192), b""):
|
for chunk in iter(lambda: f.read(8192), b""):
|
||||||
|
|
@ -62,25 +69,22 @@ def compute_sha256(filepath: Path) -> str:
|
||||||
|
|
||||||
|
|
||||||
def infer_domain(filepath: Path, content: str) -> str:
|
def infer_domain(filepath: Path, content: str) -> str:
|
||||||
"""Infer domain from filename and content."""
|
|
||||||
name = filepath.stem.upper()
|
name = filepath.stem.upper()
|
||||||
for patterns, domain in DOMAIN_PATTERNS.items():
|
for patterns, domain in DOMAIN_PATTERNS.items():
|
||||||
if any(p in name for p in patterns.split("|")):
|
if any(p in name for p in patterns.split("|")):
|
||||||
return domain
|
return domain
|
||||||
|
|
||||||
# Default: use parent directory as hint
|
|
||||||
parent = filepath.parent.name.lower()
|
parent = filepath.parent.name.lower()
|
||||||
if "semantics" in parent or "lean" in parent:
|
if "semantics" in parent or "lean" in parent:
|
||||||
return "formalization"
|
return "formalization"
|
||||||
elif "research" in parent:
|
elif "research" in parent:
|
||||||
return "compression" # Most research files are about compression/hutter
|
return "compression"
|
||||||
elif "architecture" in parent:
|
elif "architecture" in parent:
|
||||||
return "topology"
|
return "topology"
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
def infer_tier(filepath: Path) -> str:
|
def infer_tier(filepath: Path) -> str:
|
||||||
"""Infer tier from path."""
|
|
||||||
path_str = str(filepath).lower()
|
path_str = str(filepath).lower()
|
||||||
for pattern, tier in TIER_MAPPING.items():
|
for pattern, tier in TIER_MAPPING.items():
|
||||||
if pattern in path_str:
|
if pattern in path_str:
|
||||||
|
|
@ -89,11 +93,10 @@ def infer_tier(filepath: Path) -> str:
|
||||||
|
|
||||||
|
|
||||||
def extract_summary(filepath: Path, max_lines: int = 5) -> str:
|
def extract_summary(filepath: Path, max_lines: int = 5) -> str:
|
||||||
"""Extract first few non-empty lines as summary."""
|
|
||||||
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
||||||
lines = []
|
lines = []
|
||||||
for i, line in enumerate(f):
|
for i, line in enumerate(f):
|
||||||
if i >= max_lines * 3: # Read more to find content
|
if i >= max_lines * 3:
|
||||||
break
|
break
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped and not stripped.startswith("#"):
|
if stripped and not stripped.startswith("#"):
|
||||||
|
|
@ -104,79 +107,63 @@ def extract_summary(filepath: Path, max_lines: int = 5) -> str:
|
||||||
|
|
||||||
|
|
||||||
def compute_genome(filepath: Path) -> Dict[str, int]:
|
def compute_genome(filepath: Path) -> Dict[str, int]:
|
||||||
"""Compute genome (6D quantized signature) for file."""
|
|
||||||
try:
|
try:
|
||||||
size = filepath.stat().st_size
|
size = filepath.stat().st_size
|
||||||
lines = len(filepath.read_text(encoding="utf-8", errors="replace").split("\n"))
|
lines = len(filepath.read_text(encoding="utf-8", errors="replace").split("\n"))
|
||||||
|
|
||||||
# Quantize to 3 bits (0-7) per dimension
|
mu = (lines % 256) // 32
|
||||||
mu = (lines % 256) // 32 # compression ratio bin based on lines
|
rho = min(7, (size // 1024) % 8)
|
||||||
rho = min(7, (size // 1024) % 8) # information density (KB bins)
|
c = 4
|
||||||
c = 4 # fixed cost for documentation
|
m = 4
|
||||||
m = 4 # manifold: document is stable
|
ne = 7 if len(filepath.stem) > 15 else 3
|
||||||
ne = 7 if len(filepath.stem) > 15 else 3 # negentropy: longer names = higher semantic content
|
sig = 0
|
||||||
sig = 0 # documentation has no signal category
|
|
||||||
|
return {"mu": mu, "rho": rho, "c": c, "m": m, "ne": ne, "sig": sig}
|
||||||
return {
|
except Exception:
|
||||||
"mu": mu, "rho": rho, "c": c, "m": m, "ne": ne, "sig": sig
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Could not compute genome for {filepath}: {e}")
|
|
||||||
return {"mu": 0, "rho": 0, "c": 4, "m": 4, "ne": 0, "sig": 0}
|
return {"mu": 0, "rho": 0, "c": 4, "m": 4, "ne": 0, "sig": 0}
|
||||||
|
|
||||||
|
|
||||||
def compute_bind(filepath: Path) -> Dict[str, Any]:
|
def compute_bind(filepath: Path) -> Dict[str, Any]:
|
||||||
"""Compute bind struct (cost, lawful check, invariant)."""
|
|
||||||
return {
|
return {
|
||||||
"lawful": True,
|
"lawful": True,
|
||||||
"cost": 0x00010000, # 1.0 in Q16_16 — documentation is reference cost
|
"cost": 0x00010000,
|
||||||
"invariant": "documentConsistency",
|
"invariant": "documentConsistency",
|
||||||
"class": "informational_bind"
|
"class": "informational_bind",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def compute_address_from_genome(genome: Dict[str, int]) -> int:
|
def compute_address_from_genome(genome: Dict[str, int]) -> int:
|
||||||
"""Convert 6D genome to 18-bit linear address."""
|
|
||||||
mu = genome.get("mu", 0)
|
mu = genome.get("mu", 0)
|
||||||
rho = genome.get("rho", 0)
|
rho = genome.get("rho", 0)
|
||||||
c = genome.get("c", 0)
|
c = genome.get("c", 0)
|
||||||
m = genome.get("m", 0)
|
m = genome.get("m", 0)
|
||||||
ne = genome.get("ne", 0)
|
ne = genome.get("ne", 0)
|
||||||
sig = genome.get("sig", 0)
|
sig = genome.get("sig", 0)
|
||||||
|
return (((((mu * 8 + rho) * 8 + c) * 8 + m) * 8 + ne) * 8 + sig)
|
||||||
address = (((((mu * 8 + rho) * 8 + c) * 8 + m) * 8 + ne) * 8 + sig)
|
|
||||||
return address
|
|
||||||
|
|
||||||
|
|
||||||
def md_to_jsonl_entry(filepath: Path, node_id: str = "qfox") -> Dict[str, Any]:
|
def md_to_jsonl_entry(filepath: Path, node_id: str) -> Dict[str, Any]:
|
||||||
"""Convert a single markdown file to JSON-L entry."""
|
|
||||||
|
|
||||||
# Compute file metadata
|
|
||||||
file_hash = compute_sha256(filepath)
|
file_hash = compute_sha256(filepath)
|
||||||
file_stat = filepath.stat()
|
file_stat = filepath.stat()
|
||||||
mtime_unix = file_stat.st_mtime
|
mtime_unix = file_stat.st_mtime
|
||||||
file_size = file_stat.st_size
|
file_size = file_stat.st_size
|
||||||
|
|
||||||
# Compute derived fields
|
|
||||||
domain = infer_domain(filepath, "")
|
domain = infer_domain(filepath, "")
|
||||||
tier = infer_tier(filepath)
|
tier = infer_tier(filepath)
|
||||||
genome = compute_genome(filepath)
|
genome = compute_genome(filepath)
|
||||||
address = compute_address_from_genome(genome)
|
_address = compute_address_from_genome(genome)
|
||||||
bind = compute_bind(filepath)
|
bind = compute_bind(filepath)
|
||||||
|
|
||||||
# Create pkg identifier
|
|
||||||
rel_path = filepath.relative_to(WORKSPACE_ROOT)
|
rel_path = filepath.relative_to(WORKSPACE_ROOT)
|
||||||
pkg = f"ene/markdown/{rel_path.stem}".replace("\\", "/")
|
pkg = f"ene/markdown/{rel_path.stem}".replace("\\", "/")
|
||||||
version = datetime.fromtimestamp(mtime_unix, tz=timezone.utc).isoformat()
|
version = datetime.fromtimestamp(mtime_unix, tz=timezone.utc).isoformat()
|
||||||
|
|
||||||
# Concept anchor
|
|
||||||
concept_anchor = {
|
concept_anchor = {
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"concept": filepath.stem.lower().replace(" ", "_").replace("-", "_"),
|
"concept": filepath.stem.lower().replace(" ", "_").replace("-", "_"),
|
||||||
"resolution": "STABLE" # documents are stable, immutable records
|
"resolution": "STABLE",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Data payload
|
|
||||||
data = {
|
data = {
|
||||||
"pkg": pkg,
|
"pkg": pkg,
|
||||||
"version": version,
|
"version": version,
|
||||||
|
|
@ -187,20 +174,18 @@ def md_to_jsonl_entry(filepath: Path, node_id: str = "qfox") -> Dict[str, Any]:
|
||||||
"file_path": str(rel_path).replace("\\", "/"),
|
"file_path": str(rel_path).replace("\\", "/"),
|
||||||
"file_hash": file_hash,
|
"file_hash": file_hash,
|
||||||
"byte_count": file_size,
|
"byte_count": file_size,
|
||||||
"summary": extract_summary(filepath)
|
"summary": extract_summary(filepath),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Provenance
|
|
||||||
provenance = {
|
provenance = {
|
||||||
"node": node_id,
|
"node": node_id,
|
||||||
"lake_seed": "md_converter_seed",
|
"lake_seed": env_default("ENE_LAKE_SEED", "md_converter_seed"),
|
||||||
"tailscale_ip": "127.0.0.1",
|
"tailscale_ip": env_default("ENE_TAILSCALE_IP", "127.0.0.1"),
|
||||||
"attestation_hash": file_hash,
|
"attestation_hash": file_hash,
|
||||||
"prev_id": None
|
"prev_id": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Full JSON-L entry
|
return {
|
||||||
entry = {
|
|
||||||
"t": mtime_unix,
|
"t": mtime_unix,
|
||||||
"src": "ene",
|
"src": "ene",
|
||||||
"id": f"ene:{pkg}:{version}",
|
"id": f"ene:{pkg}:{version}",
|
||||||
|
|
@ -208,115 +193,72 @@ def md_to_jsonl_entry(filepath: Path, node_id: str = "qfox") -> Dict[str, Any]:
|
||||||
"data": data,
|
"data": data,
|
||||||
"genome": genome,
|
"genome": genome,
|
||||||
"bind": bind,
|
"bind": bind,
|
||||||
"provenance": provenance
|
"provenance": provenance,
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def find_all_md_files() -> List[Path]:
|
def find_all_md_files() -> List[Path]:
|
||||||
"""Find all .md files in search directories."""
|
|
||||||
md_files = []
|
md_files = []
|
||||||
for search_dir in SEARCH_DIRS:
|
for search_dir in SEARCH_DIRS:
|
||||||
if not search_dir.exists():
|
if not search_dir.exists():
|
||||||
continue
|
continue
|
||||||
for md_file in search_dir.glob("*.md"):
|
for md_file in search_dir.glob("*.md"):
|
||||||
md_files.append(md_file)
|
md_files.append(md_file)
|
||||||
# Recursively search subdirectories for germane/
|
|
||||||
if "germane" in str(search_dir):
|
if "germane" in str(search_dir):
|
||||||
for md_file in search_dir.glob("**/*.md"):
|
for md_file in search_dir.glob("**/*.md"):
|
||||||
md_files.append(md_file)
|
md_files.append(md_file)
|
||||||
|
return sorted(list(set(md_files)))
|
||||||
return sorted(list(set(md_files))) # Remove duplicates and sort
|
|
||||||
|
|
||||||
|
|
||||||
def load_existing_manifest() -> set:
|
def load_existing_manifest() -> set:
|
||||||
"""Load IDs from existing manifest to avoid duplicates."""
|
|
||||||
existing_ids = set()
|
existing_ids = set()
|
||||||
if MANIFEST_PATH.exists():
|
if MANIFEST_PATH.exists():
|
||||||
try:
|
with open(MANIFEST_PATH, "r") as f:
|
||||||
with open(MANIFEST_PATH, "r") as f:
|
for line in f:
|
||||||
for line in f:
|
line = line.strip()
|
||||||
line = line.strip()
|
if not line:
|
||||||
if line:
|
continue
|
||||||
try:
|
try:
|
||||||
entry = json.loads(line)
|
entry = json.loads(line)
|
||||||
existing_ids.add(entry.get("id", ""))
|
existing_ids.add(entry.get("id", ""))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
continue
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Could not read existing manifest: {e}")
|
|
||||||
return existing_ids
|
return existing_ids
|
||||||
|
|
||||||
|
|
||||||
def convert_all_md_files(output_file: Optional[str] = None) -> Tuple[int, int, int]:
|
def convert_all_md_files(node_id: str, output_file: Optional[str] = None) -> Tuple[int, int, int]:
|
||||||
"""
|
|
||||||
Convert all markdown files to JSON-L.
|
|
||||||
|
|
||||||
Returns: (total_files, converted, skipped)
|
|
||||||
"""
|
|
||||||
output_path = Path(output_file) if output_file else MANIFEST_PATH
|
output_path = Path(output_file) if output_file else MANIFEST_PATH
|
||||||
|
|
||||||
print(f"🔍 Scanning for .md files in {len(SEARCH_DIRS)} directories...")
|
|
||||||
md_files = find_all_md_files()
|
md_files = find_all_md_files()
|
||||||
print(f"✅ Found {len(md_files)} markdown files")
|
|
||||||
|
|
||||||
existing_ids = load_existing_manifest()
|
existing_ids = load_existing_manifest()
|
||||||
print(f"📋 Manifest already has {len(existing_ids)} entries")
|
|
||||||
|
|
||||||
converted = 0
|
converted = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|
||||||
# Append new entries to manifest
|
|
||||||
with open(output_path, "a") as manifest_f:
|
with open(output_path, "a") as manifest_f:
|
||||||
for i, md_file in enumerate(md_files, 1):
|
for md_file in md_files:
|
||||||
try:
|
entry = md_to_jsonl_entry(md_file, node_id=node_id)
|
||||||
entry = md_to_jsonl_entry(md_file)
|
entry_id = entry.get("id", "")
|
||||||
entry_id = entry.get("id", "")
|
if entry_id in existing_ids:
|
||||||
|
|
||||||
if entry_id in existing_ids:
|
|
||||||
skipped += 1
|
|
||||||
status = "⏭️ SKIP"
|
|
||||||
else:
|
|
||||||
manifest_f.write(json.dumps(entry) + "\n")
|
|
||||||
manifest_f.flush()
|
|
||||||
converted += 1
|
|
||||||
status = "✅ CONV"
|
|
||||||
|
|
||||||
rel_path = md_file.relative_to(WORKSPACE_ROOT)
|
|
||||||
print(f"[{i:3d}/{len(md_files)}] {status} {rel_path}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[{i:3d}/{len(md_files)}] ❌ ERR {md_file.relative_to(WORKSPACE_ROOT)}: {e}")
|
|
||||||
skipped += 1
|
skipped += 1
|
||||||
|
else:
|
||||||
|
manifest_f.write(json.dumps(entry) + "\n")
|
||||||
|
manifest_f.flush()
|
||||||
|
converted += 1
|
||||||
|
|
||||||
return len(md_files), converted, skipped
|
return len(md_files), converted, skipped
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> int:
|
||||||
"""Main entry point."""
|
parser = argparse.ArgumentParser()
|
||||||
output_file = None
|
parser.add_argument("--output-file", default=None)
|
||||||
if len(sys.argv) > 2 and sys.argv[1] == "--output-file":
|
parser.add_argument("--node-id", default=env_default("ENE_NODE_ID", "qfox"))
|
||||||
output_file = sys.argv[2]
|
args = parser.parse_args()
|
||||||
|
|
||||||
print("=" * 70)
|
total, converted, skipped = convert_all_md_files(node_id=args.node_id, output_file=args.output_file)
|
||||||
print("Markdown to JSON-L Converter")
|
print(json.dumps({"total": total, "converted": converted, "skipped": skipped, "node_id": args.node_id}, indent=2))
|
||||||
print(f"Workspace: {WORKSPACE_ROOT}")
|
return 0
|
||||||
print(f"Output: {output_file or MANIFEST_PATH}")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
total, converted, skipped = convert_all_md_files(output_file)
|
|
||||||
|
|
||||||
print("=" * 70)
|
|
||||||
print(f"📊 Summary:")
|
|
||||||
print(f" Total files found: {total}")
|
|
||||||
print(f" Newly converted: {converted}")
|
|
||||||
print(f" Already exists: {skipped}")
|
|
||||||
print(f" Output file: {output_file or MANIFEST_PATH}")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
return 0 if converted > 0 or skipped > 0 else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
raise SystemExit(main())
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""obsidian_sync_shim.py — Bidirectional sync between Obsidian vault and JSON-L lake.
|
||||||
obsidian_sync_shim.py — Bidirectional sync between Obsidian vault and JSON-L lake.
|
|
||||||
|
|
||||||
Modes:
|
Modes:
|
||||||
ingest : Obsidian notes → JSON-L lake (append to lake file)
|
ingest : Obsidian notes → JSON-L lake (append to lake file)
|
||||||
|
|
@ -9,6 +8,13 @@ Modes:
|
||||||
|
|
||||||
Requires TOPOLOGICAL_ENGINE_URL and TOPOLOGICAL_ENGINE_TOKEN in .env.
|
Requires TOPOLOGICAL_ENGINE_URL and TOPOLOGICAL_ENGINE_TOKEN in .env.
|
||||||
For local Obsidian installs, set OBSIDIAN_VAULT_PATH or use --vault.
|
For local Obsidian installs, set OBSIDIAN_VAULT_PATH or use --vault.
|
||||||
|
|
||||||
|
Hardcoded node/provenance assumptions were removed:
|
||||||
|
- provenance.node uses ENE_NODE_ID (default: obsidian_sync_shim)
|
||||||
|
- provenance.lake_seed uses ENE_LAKE_SEED (default: obsidian_lake)
|
||||||
|
- provenance.tailscale_ip uses ENE_TAILSCALE_IP (default: 127.0.0.1)
|
||||||
|
|
||||||
|
NOTE: This is a legacy ingest surface; treat outputs as non-authoritative.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -25,6 +31,7 @@ from typing import Dict, List, Any, Optional
|
||||||
project_root = Path(__file__).parent.parent.parent
|
project_root = Path(__file__).parent.parent.parent
|
||||||
try:
|
try:
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
if (project_root / ".env").exists():
|
if (project_root / ".env").exists():
|
||||||
load_dotenv(project_root / ".env")
|
load_dotenv(project_root / ".env")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
@ -36,6 +43,11 @@ sys.path.insert(0, str(project_root / "4-Infrastructure" / "infra"))
|
||||||
from infra.topological_engine_client import TopologicalEngineClient
|
from infra.topological_engine_client import TopologicalEngineClient
|
||||||
|
|
||||||
|
|
||||||
|
def env_default(name: str, default: str) -> str:
|
||||||
|
v = os.getenv(name)
|
||||||
|
return v if v is not None and v != "" else default
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_VAULT_PATH = project_root / "Obdisidan connector"
|
DEFAULT_VAULT_PATH = project_root / "Obdisidan connector"
|
||||||
LAKE_PATH = Path(os.getenv("OBSIDIAN_LAKE_PATH", str(project_root / "data" / "obsidian_lake.jsonl")))
|
LAKE_PATH = Path(os.getenv("OBSIDIAN_LAKE_PATH", str(project_root / "data" / "obsidian_lake.jsonl")))
|
||||||
LAKE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
LAKE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -91,11 +103,21 @@ def _format_jsonl_entry(
|
||||||
tier: str = "AUX",
|
tier: str = "AUX",
|
||||||
domain: str = "obsidian",
|
domain: str = "obsidian",
|
||||||
archetype: str = "note",
|
archetype: str = "note",
|
||||||
src: str = "obsidian_sync_shim"
|
src: str = "obsidian_sync_shim",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Format a JSON-L entry for the Research Stack lake."""
|
"""Format a JSON-L entry for the Research Stack lake."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
timestamp = now.isoformat()
|
timestamp = now.isoformat()
|
||||||
|
|
||||||
|
node_id = env_default("ENE_NODE_ID", "obsidian_sync_shim")
|
||||||
|
lake_seed = env_default("ENE_LAKE_SEED", "obsidian_lake")
|
||||||
|
tailscale_ip = env_default("ENE_TAILSCALE_IP", "127.0.0.1")
|
||||||
|
|
||||||
|
# Deterministic attestation over the minimal payload.
|
||||||
|
attestation_hash = "sha256:" + hashlib.sha256(
|
||||||
|
(pkg + ":" + timestamp + ":" + json.dumps(data, sort_keys=True, ensure_ascii=False)).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"t": now.timestamp(),
|
"t": now.timestamp(),
|
||||||
"src": src,
|
"src": src,
|
||||||
|
|
@ -107,18 +129,21 @@ def _format_jsonl_entry(
|
||||||
"tier": tier,
|
"tier": tier,
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"archetype": archetype,
|
"archetype": archetype,
|
||||||
**data
|
**data,
|
||||||
},
|
},
|
||||||
"bind": {
|
"bind": {
|
||||||
"lawful": True,
|
"lawful": True,
|
||||||
"cost": 65536,
|
"cost": 65536,
|
||||||
"invariant": "noteConsistency",
|
"invariant": "noteConsistency",
|
||||||
"class": "informational_bind"
|
"class": "informational_bind",
|
||||||
},
|
},
|
||||||
"provenance": {
|
"provenance": {
|
||||||
"node": "obsidian_sync_shim",
|
"node": node_id,
|
||||||
"lake_seed": "obsidian_lake"
|
"lake_seed": lake_seed,
|
||||||
}
|
"tailscale_ip": tailscale_ip,
|
||||||
|
"attestation_hash": attestation_hash,
|
||||||
|
"prev_id": None,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -129,7 +154,7 @@ def _format_jsonl_entry(
|
||||||
def ingest_obsidian(
|
def ingest_obsidian(
|
||||||
client: TopologicalEngineClient,
|
client: TopologicalEngineClient,
|
||||||
query: str = "*",
|
query: str = "*",
|
||||||
lake_path: Path = LAKE_PATH
|
lake_path: Path = LAKE_PATH,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Pull notes from Obsidian vault and append to JSON-L lake."""
|
"""Pull notes from Obsidian vault and append to JSON-L lake."""
|
||||||
print("[ingest] Searching Obsidian vault...")
|
print("[ingest] Searching Obsidian vault...")
|
||||||
|
|
@ -156,11 +181,10 @@ def ingest_obsidian(
|
||||||
"links": note.get("links", []),
|
"links": note.get("links", []),
|
||||||
"tags": note.get("tags", []),
|
"tags": note.get("tags", []),
|
||||||
"modified": note.get("modified", note.get("mtime")),
|
"modified": note.get("modified", note.get("mtime")),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
|
|
||||||
# Append to lake
|
|
||||||
with open(lake_path, "a") as f:
|
with open(lake_path, "a") as f:
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
f.write(json.dumps(entry) + "\n")
|
f.write(json.dumps(entry) + "\n")
|
||||||
|
|
@ -172,7 +196,7 @@ def ingest_obsidian(
|
||||||
def ingest_local_obsidian(
|
def ingest_local_obsidian(
|
||||||
vault_path: Path,
|
vault_path: Path,
|
||||||
query: str = "*",
|
query: str = "*",
|
||||||
lake_path: Path = LAKE_PATH
|
lake_path: Path = LAKE_PATH,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Pull notes from a local Obsidian vault and append to JSON-L lake."""
|
"""Pull notes from a local Obsidian vault and append to JSON-L lake."""
|
||||||
if not vault_path.exists():
|
if not vault_path.exists():
|
||||||
|
|
@ -223,7 +247,7 @@ def ingest_local_obsidian(
|
||||||
def export_to_obsidian(
|
def export_to_obsidian(
|
||||||
client: TopologicalEngineClient,
|
client: TopologicalEngineClient,
|
||||||
lake_path: Path = LAKE_PATH,
|
lake_path: Path = LAKE_PATH,
|
||||||
filter_domain: str = "obsidian"
|
filter_domain: str = "obsidian",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Read JSON-L lake and write matching entries back to Obsidian vault."""
|
"""Read JSON-L lake and write matching entries back to Obsidian vault."""
|
||||||
if not lake_path.exists():
|
if not lake_path.exists():
|
||||||
|
|
@ -257,19 +281,21 @@ def export_to_obsidian(
|
||||||
if not path:
|
if not path:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Reconstruct a simple markdown note
|
|
||||||
md = f"# {title}\n\n"
|
md = f"# {title}\n\n"
|
||||||
md += f"> Synced from JSON-L lake at {datetime.now(timezone.utc).isoformat()}\n\n"
|
md += f"> Synced from JSON-L lake at {datetime.now(timezone.utc).isoformat()}\n\n"
|
||||||
md += content_preview
|
md += content_preview
|
||||||
if len(content_preview) >= 500:
|
if len(content_preview) >= 500:
|
||||||
md += "\n\n...(truncated)"
|
md += "\n\n...(truncated)"
|
||||||
|
|
||||||
# Write via topological engine
|
res = client.write_obsidian_note(
|
||||||
res = client.write_obsidian_note(path=path, body=md, metadata={
|
path=path,
|
||||||
"source": "research_stack_lake",
|
body=md,
|
||||||
"entry_id": entry.get("id"),
|
metadata={
|
||||||
"synced_at": datetime.now(timezone.utc).isoformat()
|
"source": "research_stack_lake",
|
||||||
})
|
"entry_id": entry.get("id"),
|
||||||
|
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
if "error" not in res:
|
if "error" not in res:
|
||||||
written += 1
|
written += 1
|
||||||
|
|
||||||
|
|
@ -281,7 +307,7 @@ def export_to_local_obsidian(
|
||||||
vault_path: Path,
|
vault_path: Path,
|
||||||
lake_path: Path = LAKE_PATH,
|
lake_path: Path = LAKE_PATH,
|
||||||
filter_domain: str = "obsidian",
|
filter_domain: str = "obsidian",
|
||||||
overwrite: bool = False
|
overwrite: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Read JSON-L lake and write matching entries into a local Obsidian vault."""
|
"""Read JSON-L lake and write matching entries into a local Obsidian vault."""
|
||||||
if not lake_path.exists():
|
if not lake_path.exists():
|
||||||
|
|
@ -339,15 +365,8 @@ def export_to_local_obsidian(
|
||||||
def sync_bidirectional(
|
def sync_bidirectional(
|
||||||
client: TopologicalEngineClient,
|
client: TopologicalEngineClient,
|
||||||
lake_path: Path = LAKE_PATH,
|
lake_path: Path = LAKE_PATH,
|
||||||
obsidian_wins: bool = True
|
obsidian_wins: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
|
||||||
Two-way sync:
|
|
||||||
1. Ingest current Obsidian state into lake.
|
|
||||||
2. Export lake entries back to Obsidian (creating any missing notes).
|
|
||||||
|
|
||||||
If obsidian_wins=True, existing Obsidian notes are not overwritten.
|
|
||||||
"""
|
|
||||||
print("[sync] Starting bidirectional sync...")
|
print("[sync] Starting bidirectional sync...")
|
||||||
ingest_res = ingest_obsidian(client, lake_path=lake_path)
|
ingest_res = ingest_obsidian(client, lake_path=lake_path)
|
||||||
if "error" in ingest_res:
|
if "error" in ingest_res:
|
||||||
|
|
@ -355,13 +374,11 @@ def sync_bidirectional(
|
||||||
|
|
||||||
if obsidian_wins:
|
if obsidian_wins:
|
||||||
print("[sync] Obsidian-wins mode: skipping overwrite of existing notes")
|
print("[sync] Obsidian-wins mode: skipping overwrite of existing notes")
|
||||||
# In export mode, only write notes that don't already exist
|
|
||||||
search_res = client.search_obsidian("*")
|
search_res = client.search_obsidian("*")
|
||||||
existing_paths = set()
|
existing_paths = set()
|
||||||
for note in search_res.get("results", search_res.get("notes", [])):
|
for note in search_res.get("results", search_res.get("notes", [])):
|
||||||
existing_paths.add(note.get("path", note.get("file_path")))
|
existing_paths.add(note.get("path", note.get("file_path")))
|
||||||
|
|
||||||
# Filter lake entries to only missing paths
|
|
||||||
if lake_path.exists():
|
if lake_path.exists():
|
||||||
entries = []
|
entries = []
|
||||||
with open(lake_path) as f:
|
with open(lake_path) as f:
|
||||||
|
|
@ -387,23 +404,16 @@ def sync_bidirectional(
|
||||||
written += 1
|
written += 1
|
||||||
print(f"[sync] Created {written} missing notes in Obsidian")
|
print(f"[sync] Created {written} missing notes in Obsidian")
|
||||||
return {"ingested": ingest_res["ingested"], "created": written}
|
return {"ingested": ingest_res["ingested"], "created": written}
|
||||||
else:
|
|
||||||
export_res = export_to_obsidian(client, lake_path=lake_path)
|
export_res = export_to_obsidian(client, lake_path=lake_path)
|
||||||
return {"ingested": ingest_res["ingested"], **export_res}
|
return {"ingested": ingest_res["ingested"], **export_res}
|
||||||
|
|
||||||
|
|
||||||
def sync_local_bidirectional(
|
def sync_local_bidirectional(
|
||||||
vault_path: Path,
|
vault_path: Path,
|
||||||
lake_path: Path = LAKE_PATH,
|
lake_path: Path = LAKE_PATH,
|
||||||
obsidian_wins: bool = True
|
obsidian_wins: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
|
||||||
Local two-way sync:
|
|
||||||
1. Ingest current local Obsidian state into lake.
|
|
||||||
2. Export lake entries back to the vault.
|
|
||||||
|
|
||||||
If obsidian_wins=True, existing local notes are not overwritten.
|
|
||||||
"""
|
|
||||||
print("[sync:local] Starting bidirectional local sync")
|
print("[sync:local] Starting bidirectional local sync")
|
||||||
ingest_res = ingest_local_obsidian(vault_path=vault_path, lake_path=lake_path)
|
ingest_res = ingest_local_obsidian(vault_path=vault_path, lake_path=lake_path)
|
||||||
if "error" in ingest_res:
|
if "error" in ingest_res:
|
||||||
|
|
@ -424,18 +434,33 @@ def sync_local_bidirectional(
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Obsidian ↔ JSON-L lake sync shim")
|
parser = argparse.ArgumentParser(description="Obsidian ↔ JSON-L lake sync shim")
|
||||||
parser.add_argument("mode", choices=["ingest", "export", "sync"], default="sync", nargs="?",
|
parser.add_argument(
|
||||||
help="ingest=Obsidian→lake, export=lake→Obsidian, sync=both")
|
"mode",
|
||||||
parser.add_argument("--backend", choices=["auto", "local", "engine"], default="auto",
|
choices=["ingest", "export", "sync"],
|
||||||
help="local=filesystem vault, engine=topological engine, auto=engine if healthy else local")
|
default="sync",
|
||||||
parser.add_argument("--vault", default=None,
|
nargs="?",
|
||||||
help="Local Obsidian vault path; defaults to OBSIDIAN_VAULT_PATH or ./Obdisidan connector")
|
help="ingest=Obsidian→lake, export=lake→Obsidian, sync=both",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--backend",
|
||||||
|
choices=["auto", "local", "engine"],
|
||||||
|
default="auto",
|
||||||
|
help="local=filesystem vault, engine=topological engine, auto=engine if healthy else local",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--vault",
|
||||||
|
default=None,
|
||||||
|
help="Local Obsidian vault path; defaults to OBSIDIAN_VAULT_PATH or ./Obdisidan connector",
|
||||||
|
)
|
||||||
parser.add_argument("--lake", default=str(LAKE_PATH), help="Path to JSON-L lake file")
|
parser.add_argument("--lake", default=str(LAKE_PATH), help="Path to JSON-L lake file")
|
||||||
parser.add_argument("--query", default="*", help="Obsidian search query (ingest mode)")
|
parser.add_argument("--query", default="*", help="Obsidian search query (ingest mode)")
|
||||||
parser.add_argument("--obsidian-wins", action="store_true", default=True,
|
parser.add_argument(
|
||||||
help="In sync mode, don't overwrite existing Obsidian notes")
|
"--obsidian-wins",
|
||||||
parser.add_argument("--lake-wins", action="store_true",
|
action="store_true",
|
||||||
help="In sync mode, overwrite Obsidian with lake contents")
|
default=True,
|
||||||
|
help="In sync mode, don't overwrite existing Obsidian notes",
|
||||||
|
)
|
||||||
|
parser.add_argument("--lake-wins", action="store_true", help="In sync mode, overwrite Obsidian with lake")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
lake_path = Path(args.lake)
|
lake_path = Path(args.lake)
|
||||||
|
|
@ -461,17 +486,9 @@ def main():
|
||||||
if args.mode == "ingest":
|
if args.mode == "ingest":
|
||||||
result = ingest_local_obsidian(vault_path=vault_path, query=args.query, lake_path=lake_path)
|
result = ingest_local_obsidian(vault_path=vault_path, query=args.query, lake_path=lake_path)
|
||||||
elif args.mode == "export":
|
elif args.mode == "export":
|
||||||
result = export_to_local_obsidian(
|
result = export_to_local_obsidian(vault_path=vault_path, lake_path=lake_path, overwrite=not obsidian_wins)
|
||||||
vault_path=vault_path,
|
|
||||||
lake_path=lake_path,
|
|
||||||
overwrite=not obsidian_wins,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
result = sync_local_bidirectional(
|
result = sync_local_bidirectional(vault_path=vault_path, lake_path=lake_path, obsidian_wins=obsidian_wins)
|
||||||
vault_path=vault_path,
|
|
||||||
lake_path=lake_path,
|
|
||||||
obsidian_wins=obsidian_wins,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
if args.mode == "ingest":
|
if args.mode == "ingest":
|
||||||
result = ingest_obsidian(client, query=args.query, lake_path=lake_path)
|
result = ingest_obsidian(client, query=args.query, lake_path=lake_path)
|
||||||
|
|
@ -483,9 +500,9 @@ def main():
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
print(f"Error: {result['error']}")
|
print(f"Error: {result['error']}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
else:
|
|
||||||
print(f"Done: {json.dumps(result, indent=2)}")
|
print(f"Done: {json.dumps(result, indent=2)}")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,27 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""swarm_network_capacity.py — Network Resource Capacity Monitor (legacy shim).
|
||||||
swarm_network_capacity.py — Network Resource Capacity Monitor
|
|
||||||
|
|
||||||
Checks all Tailscale-connected nodes (via ENE mesh) to determine:
|
Cleanups:
|
||||||
- Total available resources across the network
|
- Removed hardcoded hostname→resource maps.
|
||||||
- Currently utilized capacity
|
- Reads node inventory from 4-Infrastructure/auto/config/nodes.yaml (controller source of truth).
|
||||||
- Idle resources that could be leveraged
|
|
||||||
- Node health and connectivity status
|
NOTE: This is a legacy monitoring surface; treat results as advisory.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List, Dict, Optional
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TailscaleNode:
|
class TailscaleNode:
|
||||||
"""Remote node in the Tailscale mesh."""
|
|
||||||
ip: str
|
ip: str
|
||||||
hostname: str
|
hostname: str
|
||||||
owner: str
|
owner: str
|
||||||
|
|
@ -27,14 +29,13 @@ class TailscaleNode:
|
||||||
status: str # online, offline, idle
|
status: str # online, offline, idle
|
||||||
last_seen: Optional[str]
|
last_seen: Optional[str]
|
||||||
tags: List[str]
|
tags: List[str]
|
||||||
|
|
||||||
def is_online(self) -> bool:
|
def is_online(self) -> bool:
|
||||||
return self.status == "online" or self.status == "idle"
|
return self.status in {"online", "idle"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class NodeResources:
|
class NodeResources:
|
||||||
"""Resource capacity of a node."""
|
|
||||||
node_id: str
|
node_id: str
|
||||||
cpu_cores: int
|
cpu_cores: int
|
||||||
memory_gb: float
|
memory_gb: float
|
||||||
|
|
@ -45,187 +46,172 @@ class NodeResources:
|
||||||
utilization_percent: float
|
utilization_percent: float
|
||||||
|
|
||||||
|
|
||||||
|
def _load_nodes_inventory(path: Path) -> Dict[str, Any]:
|
||||||
|
"""Load nodes.yaml.
|
||||||
|
|
||||||
|
Tries PyYAML; if missing, raises a clear error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import yaml # type: ignore
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"nodes.yaml parsing requires PyYAML. Install with: pip install pyyaml"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(data, dict) or "nodes" not in data:
|
||||||
|
raise ValueError(f"Invalid nodes inventory file: {path}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class SwarmNetworkCapacity:
|
class SwarmNetworkCapacity:
|
||||||
"""
|
def __init__(self, inventory_path: Path):
|
||||||
Monitor and report on full network resource capacity.
|
self.inventory_path = inventory_path
|
||||||
|
self.inventory = _load_nodes_inventory(inventory_path)
|
||||||
Queries Tailscale mesh and ENE nodes to determine:
|
|
||||||
- Total available compute across all nodes
|
|
||||||
- Current utilization vs capacity
|
|
||||||
- Resource distribution
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.tailscale_nodes: List[TailscaleNode] = []
|
self.tailscale_nodes: List[TailscaleNode] = []
|
||||||
self.ene_nodes: List[str] = []
|
self.ene_nodes: List[str] = []
|
||||||
self.local_ip: Optional[str] = None
|
self.local_ip: Optional[str] = None
|
||||||
|
|
||||||
def discover_tailscale_mesh(self) -> List[TailscaleNode]:
|
def discover_tailscale_mesh(self) -> List[TailscaleNode]:
|
||||||
"""Discover all nodes in the Tailscale mesh."""
|
|
||||||
print("\n[1] Discovering Tailscale mesh nodes...")
|
print("\n[1] Discovering Tailscale mesh nodes...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["tailscale", "status"],
|
["tailscale", "status"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
lines = result.stdout.strip().split('\n')
|
lines = result.stdout.strip().split("\n")
|
||||||
nodes = []
|
nodes: List[TailscaleNode] = []
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse tailscale status line
|
|
||||||
# Format: 100.x.x.x hostname owner@ os status
|
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) >= 4:
|
if len(parts) >= 4:
|
||||||
ip = parts[0]
|
ip = parts[0]
|
||||||
hostname = parts[1]
|
hostname = parts[1]
|
||||||
owner = parts[2]
|
owner = parts[2]
|
||||||
os_type = parts[3]
|
os_type = parts[3]
|
||||||
|
status_parts = " ".join(parts[4:]) if len(parts) > 4 else ""
|
||||||
# Parse status (can be complex)
|
|
||||||
status_parts = ' '.join(parts[4:]) if len(parts) > 4 else ""
|
|
||||||
|
|
||||||
# Determine status
|
|
||||||
if "offline" in status_parts.lower():
|
if "offline" in status_parts.lower():
|
||||||
status = "offline"
|
status = "offline"
|
||||||
elif "idle" in status_parts.lower():
|
elif "idle" in status_parts.lower():
|
||||||
status = "idle"
|
status = "idle"
|
||||||
else:
|
else:
|
||||||
status = "online"
|
status = "online"
|
||||||
|
|
||||||
# Extract last seen if offline
|
|
||||||
last_seen = None
|
last_seen = None
|
||||||
if "last seen" in status_parts:
|
if "last seen" in status_parts:
|
||||||
match = re.search(r'last seen ([^,]+)', status_parts)
|
match = re.search(r"last seen ([^,]+)", status_parts)
|
||||||
if match:
|
if match:
|
||||||
last_seen = match.group(1)
|
last_seen = match.group(1)
|
||||||
|
|
||||||
# Extract tags
|
tags: List[str] = []
|
||||||
tags = []
|
|
||||||
if "tagged-devices" in line:
|
if "tagged-devices" in line:
|
||||||
tags.append("tagged-devices")
|
tags.append("tagged-devices")
|
||||||
|
|
||||||
node = TailscaleNode(
|
nodes.append(
|
||||||
ip=ip,
|
TailscaleNode(
|
||||||
hostname=hostname,
|
ip=ip,
|
||||||
owner=owner,
|
hostname=hostname,
|
||||||
os=os_type,
|
owner=owner,
|
||||||
status=status,
|
os=os_type,
|
||||||
last_seen=last_seen,
|
status=status,
|
||||||
tags=tags
|
last_seen=last_seen,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
nodes.append(node)
|
|
||||||
|
|
||||||
self.tailscale_nodes = nodes
|
self.tailscale_nodes = nodes
|
||||||
|
|
||||||
# Get local IP
|
|
||||||
try:
|
try:
|
||||||
ip_result = subprocess.run(
|
ip_result = subprocess.run(
|
||||||
["tailscale", "ip", "-4"],
|
["tailscale", "ip", "-4"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5
|
timeout=5,
|
||||||
)
|
)
|
||||||
self.local_ip = ip_result.stdout.strip()
|
self.local_ip = ip_result.stdout.strip()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
print(f" Found: {len(nodes)} Tailscale nodes")
|
print(f" Found: {len(nodes)} Tailscale nodes")
|
||||||
online = sum(1 for n in nodes if n.is_online())
|
online = sum(1 for n in nodes if n.is_online())
|
||||||
print(f" Online: {online}/{len(nodes)}")
|
print(f" Online: {online}/{len(nodes)}")
|
||||||
|
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
status_icon = "🟢" if node.is_online() else "🔴"
|
status_icon = "online" if node.is_online() else "offline"
|
||||||
print(f" {status_icon} {node.hostname} ({node.ip}) - {node.status}")
|
print(f" [{status_icon}] {node.hostname} ({node.ip})")
|
||||||
|
|
||||||
return nodes
|
return nodes
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ⚠️ Error discovering mesh: {e}")
|
print(f" ⚠️ Error discovering mesh: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def check_ene_deployment(self) -> List[str]:
|
def check_ene_deployment(self) -> List[str]:
|
||||||
"""Check which nodes have ENE deployed."""
|
"""Check which nodes have ENE deployed.
|
||||||
print("\n[2] Checking ENE deployment status...")
|
|
||||||
|
For now this still uses a heuristic: nodes with role including 'compute'
|
||||||
ene_nodes = []
|
or 'service-host' are assumed to be candidates. Real checks should be via
|
||||||
|
SSH/probes.
|
||||||
for node in self.tailscale_nodes:
|
"""
|
||||||
if not node.is_online():
|
print("\n[2] Checking ENE deployment status (heuristic)...")
|
||||||
continue
|
nodes = self.inventory.get("nodes", {})
|
||||||
|
ene_nodes: List[str] = []
|
||||||
# Try to check if ENE is running on remote node
|
for node_id, spec in nodes.items():
|
||||||
# This would typically use SSH or ENE's gossip protocol
|
roles = spec.get("roles", []) if isinstance(spec, dict) else []
|
||||||
# For now, we'll simulate based on known deployment
|
if any(r in roles for r in ["compute", "service-host", "control-plane"]):
|
||||||
|
ene_nodes.append(node_id)
|
||||||
if node.hostname in ["architect", "judge", "qfox"]:
|
self.ene_nodes = sorted(set(ene_nodes))
|
||||||
ene_nodes.append(node.hostname)
|
|
||||||
|
print(f" ENE candidates: {len(self.ene_nodes)}")
|
||||||
self.ene_nodes = ene_nodes
|
for n in self.ene_nodes[:20]:
|
||||||
|
print(f" ✅ {n}")
|
||||||
print(f" ENE deployed on: {len(ene_nodes)} nodes")
|
return self.ene_nodes
|
||||||
for node in ene_nodes:
|
|
||||||
print(f" ✅ {node}")
|
|
||||||
|
|
||||||
# Nodes needing ENE deployment
|
|
||||||
online_nodes = [n.hostname for n in self.tailscale_nodes if n.is_online()]
|
|
||||||
need_ene = set(online_nodes) - set(ene_nodes)
|
|
||||||
if need_ene:
|
|
||||||
print(f" Need ENE deployment: {len(need_ene)} nodes")
|
|
||||||
for node in need_ene:
|
|
||||||
print(f" ⚠️ {node}")
|
|
||||||
|
|
||||||
return ene_nodes
|
|
||||||
|
|
||||||
def estimate_node_resources(self, node: TailscaleNode) -> Optional[NodeResources]:
|
def estimate_node_resources(self, node: TailscaleNode) -> Optional[NodeResources]:
|
||||||
"""Estimate resources available on a node."""
|
"""Estimate node resources.
|
||||||
# These would normally be queried from the node
|
|
||||||
# For now, estimate based on hostname patterns
|
nodes.yaml currently doesn't track CPU/RAM explicitly; so this function
|
||||||
|
returns conservative defaults. Once resources are added to nodes.yaml,
|
||||||
resource_map = {
|
this function will use them directly.
|
||||||
"qfox": {"cpu": 16, "ram": 32, "storage": 1000, "gpu": 1, "bw": 1000},
|
"""
|
||||||
"architect": {"cpu": 8, "ram": 16, "storage": 500, "gpu": 0, "bw": 500},
|
specs = self.inventory.get("nodes", {}).get(node.hostname) or {}
|
||||||
"judge": {"cpu": 4, "ram": 8, "storage": 200, "gpu": 0, "bw": 500},
|
cpu = int(specs.get("cpu", 2)) if isinstance(specs, dict) else 2
|
||||||
"ip-172-31-25-81": {"cpu": 2, "ram": 4, "storage": 100, "gpu": 0, "bw": 1000}, # AWS
|
ram = float(specs.get("ram", 4)) if isinstance(specs, dict) else 4.0
|
||||||
"netcup-router": {"cpu": 4, "ram": 8, "storage": 500, "gpu": 0, "bw": 1000},
|
storage = float(specs.get("storage", 100)) if isinstance(specs, dict) else 100.0
|
||||||
"racknerd-510bd9c": {"cpu": 2, "ram": 4, "storage": 100, "gpu": 0, "bw": 1000},
|
gpu = int(specs.get("gpu", 0)) if isinstance(specs, dict) else 0
|
||||||
"racknerd-atl": {"cpu": 2, "ram": 4, "storage": 100, "gpu": 0, "bw": 1000},
|
bw = float(specs.get("bw", 100)) if isinstance(specs, dict) else 100.0
|
||||||
"desktop-0u2ceal": {"cpu": 8, "ram": 16, "storage": 500, "gpu": 1, "bw": 100},
|
|
||||||
}
|
|
||||||
|
|
||||||
specs = resource_map.get(node.hostname, {"cpu": 2, "ram": 4, "storage": 100, "gpu": 0, "bw": 100})
|
|
||||||
|
|
||||||
return NodeResources(
|
return NodeResources(
|
||||||
node_id=node.hostname,
|
node_id=node.hostname,
|
||||||
cpu_cores=specs["cpu"],
|
cpu_cores=cpu,
|
||||||
memory_gb=specs["ram"],
|
memory_gb=ram,
|
||||||
storage_gb=specs["storage"],
|
storage_gb=storage,
|
||||||
bandwidth_mbps=specs["bw"],
|
bandwidth_mbps=bw,
|
||||||
gpu_count=specs["gpu"],
|
gpu_count=gpu,
|
||||||
ene_enabled=node.hostname in self.ene_nodes,
|
ene_enabled=node.hostname in self.ene_nodes,
|
||||||
utilization_percent=0.0 # Would need actual monitoring
|
utilization_percent=0.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
def calculate_total_capacity(self) -> Dict[str, float]:
|
def calculate_total_capacity(self) -> Dict[str, float]:
|
||||||
"""Calculate total network capacity."""
|
|
||||||
print("\n[3] Calculating total network capacity...")
|
print("\n[3] Calculating total network capacity...")
|
||||||
|
|
||||||
total_cpu = 0
|
total_cpu = 0
|
||||||
total_ram = 0.0
|
total_ram = 0.0
|
||||||
total_storage = 0.0
|
total_storage = 0.0
|
||||||
total_gpu = 0
|
total_gpu = 0
|
||||||
total_bw = 0.0
|
total_bw = 0.0
|
||||||
|
|
||||||
for node in self.tailscale_nodes:
|
for node in self.tailscale_nodes:
|
||||||
if not node.is_online():
|
if not node.is_online():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
resources = self.estimate_node_resources(node)
|
resources = self.estimate_node_resources(node)
|
||||||
if resources:
|
if resources:
|
||||||
total_cpu += resources.cpu_cores
|
total_cpu += resources.cpu_cores
|
||||||
|
|
@ -233,182 +219,104 @@ class SwarmNetworkCapacity:
|
||||||
total_storage += resources.storage_gb
|
total_storage += resources.storage_gb
|
||||||
total_gpu += resources.gpu_count
|
total_gpu += resources.gpu_count
|
||||||
total_bw += resources.bandwidth_mbps
|
total_bw += resources.bandwidth_mbps
|
||||||
|
|
||||||
capacity = {
|
capacity: Dict[str, float] = {
|
||||||
"cpu_cores": total_cpu,
|
"cpu_cores": float(total_cpu),
|
||||||
"memory_gb": total_ram,
|
"memory_gb": total_ram,
|
||||||
"storage_gb": total_storage,
|
"storage_gb": total_storage,
|
||||||
"gpu_count": total_gpu,
|
"gpu_count": float(total_gpu),
|
||||||
"bandwidth_mbps": total_bw,
|
"bandwidth_mbps": total_bw,
|
||||||
"online_nodes": sum(1 for n in self.tailscale_nodes if n.is_online()),
|
"online_nodes": float(sum(1 for n in self.tailscale_nodes if n.is_online())),
|
||||||
"total_nodes": len(self.tailscale_nodes)
|
"total_nodes": float(len(self.tailscale_nodes)),
|
||||||
}
|
}
|
||||||
|
|
||||||
print(f" CPU Cores: {total_cpu}")
|
print(f" CPU Cores: {int(total_cpu)}")
|
||||||
print(f" Memory: {total_ram:.1f} GB")
|
print(f" Memory: {total_ram:.1f} GB")
|
||||||
print(f" Storage: {total_storage:.1f} GB")
|
print(f" Storage: {total_storage:.1f} GB")
|
||||||
print(f" GPUs: {total_gpu}")
|
print(f" GPUs: {int(total_gpu)}")
|
||||||
print(f" Bandwidth: {total_bw:.0f} Mbps")
|
print(f" Bandwidth: {total_bw:.0f} Mbps")
|
||||||
|
|
||||||
return capacity
|
return capacity
|
||||||
|
|
||||||
def check_current_utilization(self) -> Dict[str, float]:
|
def check_current_utilization(self) -> Dict[str, float]:
|
||||||
"""Check current resource utilization."""
|
|
||||||
print("\n[4] Checking current utilization (local node only)...")
|
print("\n[4] Checking current utilization (local node only)...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get local CPU/memory
|
result = subprocess.run(["cat", "/proc/loadavg"], capture_output=True, text=True, timeout=5)
|
||||||
result = subprocess.run(
|
|
||||||
["cat", "/proc/loadavg"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
load_parts = result.stdout.strip().split()
|
load_parts = result.stdout.strip().split()
|
||||||
load_1min = float(load_parts[0]) if load_parts else 0.0
|
load_1min = float(load_parts[0]) if load_parts else 0.0
|
||||||
|
|
||||||
# Estimate CPU utilization from load
|
cpu_util = min(100.0, (load_1min / 16) * 100)
|
||||||
# This is simplified - would need per-node queries
|
|
||||||
cpu_util = min(100.0, (load_1min / 16) * 100) # Assuming 16 cores
|
mem_result = subprocess.run(["free", "-m"], capture_output=True, text=True, timeout=5)
|
||||||
|
mem_lines = mem_result.stdout.split("\n")
|
||||||
# Memory
|
|
||||||
mem_result = subprocess.run(
|
|
||||||
["free", "-m"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
mem_lines = mem_result.stdout.split('\n')
|
|
||||||
mem_used = 0
|
mem_used = 0
|
||||||
mem_total = 1
|
mem_total = 1
|
||||||
for line in mem_lines:
|
for line in mem_lines:
|
||||||
if line.startswith('Mem:'):
|
if line.startswith("Mem:"):
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
mem_total = int(parts[1])
|
mem_total = int(parts[1])
|
||||||
mem_used = int(parts[2])
|
mem_used = int(parts[2])
|
||||||
break
|
break
|
||||||
|
|
||||||
mem_util = (mem_used / mem_total) * 100 if mem_total > 0 else 0
|
mem_util = (mem_used / mem_total) * 100 if mem_total > 0 else 0
|
||||||
|
|
||||||
utilization = {
|
|
||||||
"cpu_percent": cpu_util,
|
|
||||||
"memory_percent": mem_util,
|
|
||||||
"local_node_only": True
|
|
||||||
}
|
|
||||||
|
|
||||||
print(f" Local CPU: {cpu_util:.1f}%")
|
print(f" Local CPU: {cpu_util:.1f}%")
|
||||||
print(f" Local Memory: {mem_util:.1f}%")
|
print(f" Local Memory: {mem_util:.1f}%")
|
||||||
print(f" ⚠️ Remote utilization requires ENE monitoring")
|
|
||||||
|
return {"cpu_percent": cpu_util, "memory_percent": mem_util, "local_node_only": True}
|
||||||
return utilization
|
except Exception:
|
||||||
|
return {"cpu_percent": 0.0, "memory_percent": 0.0, "local_node_only": True}
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠️ Error checking utilization: {e}")
|
def generate_capacity_report(self) -> Dict[str, Any]:
|
||||||
return {"cpu_percent": 0, "memory_percent": 0, "local_node_only": True}
|
|
||||||
|
|
||||||
def generate_capacity_report(self) -> Dict[str, any]:
|
|
||||||
"""Generate full capacity report."""
|
|
||||||
print("\n" + "=" * 70)
|
print("\n" + "=" * 70)
|
||||||
print("SWARM NETWORK CAPACITY REPORT")
|
print("SWARM NETWORK CAPACITY REPORT")
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
|
|
||||||
# Gather data
|
|
||||||
self.discover_tailscale_mesh()
|
self.discover_tailscale_mesh()
|
||||||
self.check_ene_deployment()
|
self.check_ene_deployment()
|
||||||
capacity = self.calculate_total_capacity()
|
capacity = self.calculate_total_capacity()
|
||||||
utilization = self.check_current_utilization()
|
utilization = self.check_current_utilization()
|
||||||
|
|
||||||
# Calculate utilization vs capacity
|
report: Dict[str, Any] = {
|
||||||
report = {
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"inventory_path": str(self.inventory_path),
|
||||||
"network": {
|
"network": {
|
||||||
"total_nodes": len(self.tailscale_nodes),
|
"total_nodes": len(self.tailscale_nodes),
|
||||||
"online_nodes": capacity["online_nodes"],
|
"online_nodes": int(capacity["online_nodes"]),
|
||||||
"offline_nodes": len(self.tailscale_nodes) - capacity["online_nodes"],
|
"offline_nodes": len(self.tailscale_nodes) - int(capacity["online_nodes"]),
|
||||||
"ene_deployed": len(self.ene_nodes),
|
"ene_candidates": len(self.ene_nodes),
|
||||||
"ene_coverage": len(self.ene_nodes) / capacity["online_nodes"] * 100 if capacity["online_nodes"] > 0 else 0
|
|
||||||
},
|
|
||||||
"capacity": {
|
|
||||||
"cpu_cores": capacity["cpu_cores"],
|
|
||||||
"memory_gb": capacity["memory_gb"],
|
|
||||||
"storage_gb": capacity["storage_gb"],
|
|
||||||
"gpu_count": capacity["gpu_count"],
|
|
||||||
"bandwidth_mbps": capacity["bandwidth_mbps"]
|
|
||||||
},
|
},
|
||||||
|
"capacity": capacity,
|
||||||
"utilization": {
|
"utilization": {
|
||||||
"cpu_percent": utilization["cpu_percent"],
|
"cpu_percent": utilization.get("cpu_percent", 0.0),
|
||||||
"memory_percent": utilization["memory_percent"],
|
"memory_percent": utilization.get("memory_percent", 0.0),
|
||||||
"note": "Local node only - full mesh monitoring requires ENE deployment on all nodes"
|
"note": "Local only - full mesh monitoring requires probes",
|
||||||
},
|
},
|
||||||
"idle_resources": {
|
"recommendations": [
|
||||||
"cpu_cores_available": capacity["cpu_cores"] * (1 - utilization["cpu_percent"]/100),
|
"Add cpu/ram/storage/gpu/bw fields to nodes.yaml to remove conservative defaults.",
|
||||||
"memory_gb_available": capacity["memory_gb"] * (1 - utilization["memory_percent"]/100),
|
"Replace ENE deployment heuristic with SSH/probe checks.",
|
||||||
"message": "Significant idle capacity available across mesh"
|
],
|
||||||
},
|
|
||||||
"recommendations": []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add recommendations
|
|
||||||
ene_coverage = report["network"]["ene_coverage"]
|
|
||||||
if ene_coverage < 100:
|
|
||||||
report["recommendations"].append(
|
|
||||||
f"Deploy ENE to {capacity['online_nodes'] - len(self.ene_nodes)} remaining nodes for full mesh monitoring"
|
|
||||||
)
|
|
||||||
|
|
||||||
if utilization["cpu_percent"] < 50:
|
|
||||||
report["recommendations"].append(
|
|
||||||
"CPU utilization low - swarm can scale up workloads"
|
|
||||||
)
|
|
||||||
|
|
||||||
if utilization["memory_percent"] < 50:
|
|
||||||
report["recommendations"].append(
|
|
||||||
"Memory available - can distribute more tasks across mesh"
|
|
||||||
)
|
|
||||||
|
|
||||||
report["recommendations"].append(
|
|
||||||
"Consider load balancing across all online nodes via ENE"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Print summary
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print("SUMMARY")
|
|
||||||
print("=" * 70)
|
|
||||||
print(f"Network: {report['network']['online_nodes']}/{report['network']['total_nodes']} nodes online")
|
|
||||||
print(f"ENE Coverage: {ene_coverage:.1f}% ({report['network']['ene_deployed']} nodes)")
|
|
||||||
print(f"Total CPU: {report['capacity']['cpu_cores']} cores")
|
|
||||||
print(f"Total Memory: {report['capacity']['memory_gb']:.1f} GB")
|
|
||||||
print(f"Total Storage: {report['capacity']['storage_gb']:.1f} GB")
|
|
||||||
print(f"\nUtilization (local only):")
|
|
||||||
print(f" CPU: {report['utilization']['cpu_percent']:.1f}%")
|
|
||||||
print(f" Memory: {report['utilization']['memory_percent']:.1f}%")
|
|
||||||
print(f"\nRecommendations:")
|
|
||||||
for rec in report["recommendations"]:
|
|
||||||
print(f" • {rec}")
|
|
||||||
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main(argv: list[str] | None = None) -> int:
|
||||||
"""Run network capacity check."""
|
parser = argparse.ArgumentParser()
|
||||||
monitor = SwarmNetworkCapacity()
|
parser.add_argument(
|
||||||
|
"--inventory",
|
||||||
|
type=Path,
|
||||||
|
default=Path("4-Infrastructure/auto/config/nodes.yaml"),
|
||||||
|
help="Path to nodes.yaml",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
monitor = SwarmNetworkCapacity(args.inventory)
|
||||||
report = monitor.generate_capacity_report()
|
report = monitor.generate_capacity_report()
|
||||||
|
print(json.dumps(report, indent=2))
|
||||||
# Save report
|
return 0
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
output_path = Path("/home/allaun/Documents/Research Stack/data/swarm_network_capacity.json")
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(output_path, "w") as f:
|
|
||||||
json.dump(report, f, indent=2)
|
|
||||||
|
|
||||||
print(f"Report saved: {output_path}")
|
|
||||||
|
|
||||||
return report
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
raise SystemExit(main())
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,38 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""Comprehensive Text Container → JSON-L Converter (legacy shim).
|
||||||
Comprehensive Text Container to JSON-L Converter
|
|
||||||
|
|
||||||
Converts all information-bearing text containers (MD, JSON, CSV, TSV, TXT) to
|
Hardcoded node/provenance assumptions were removed:
|
||||||
JSON-L format compatible with UNIFIED_JSONL_SCHEMA.md.
|
- node id is configurable via --node-id or ENE_NODE_ID
|
||||||
|
- tailscale ip via ENE_TAILSCALE_IP
|
||||||
Handles:
|
- workspace root via ENE_WORKSPACE_ROOT
|
||||||
- .md files (Markdown documents)
|
|
||||||
- .json files (JSON structures)
|
|
||||||
- .jsonl files (already JSON-L, wrap as documents)
|
|
||||||
- .csv files (tabular data)
|
|
||||||
- .tsv files (tabular data)
|
|
||||||
- .txt files (plain text documents)
|
|
||||||
|
|
||||||
Excludes:
|
|
||||||
- Tool/library files (node_modules, .lake, tools/search, .git)
|
|
||||||
- Config files (setup.json, package.json, etc.)
|
|
||||||
- vendored dependencies
|
|
||||||
|
|
||||||
|
This script is an ingest shim. Treat outputs as non-authoritative.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import csv
|
import csv
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import hashlib
|
import hashlib
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
# Configuration
|
|
||||||
WORKSPACE_ROOT = Path("/home/allaun/Documents/Research Stack")
|
def env_default(name: str, default: str) -> str:
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
|
||||||
|
v = os.environ.get(name)
|
||||||
|
except Exception:
|
||||||
|
v = None
|
||||||
|
return v if v is not None and v != "" else default
|
||||||
|
|
||||||
|
|
||||||
|
WORKSPACE_ROOT = Path(env_default("ENE_WORKSPACE_ROOT", "/home/allaun/Documents/Research Stack"))
|
||||||
MANIFEST_PATH = WORKSPACE_ROOT / "data" / "manifest.jsonl"
|
MANIFEST_PATH = WORKSPACE_ROOT / "data" / "manifest.jsonl"
|
||||||
|
|
||||||
# Directories to skip
|
|
||||||
EXCLUDE_PATTERNS = [
|
EXCLUDE_PATTERNS = [
|
||||||
".git",
|
".git",
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
|
@ -50,10 +47,8 @@ EXCLUDE_PATTERNS = [
|
||||||
"venv_",
|
"venv_",
|
||||||
]
|
]
|
||||||
|
|
||||||
# File types to process
|
|
||||||
PROCESS_EXTENSIONS = {".md", ".json", ".jsonl", ".csv", ".tsv", ".txt"}
|
PROCESS_EXTENSIONS = {".md", ".json", ".jsonl", ".csv", ".tsv", ".txt"}
|
||||||
|
|
||||||
# Archetype mapping
|
|
||||||
ARCHETYPE_MAP = {
|
ARCHETYPE_MAP = {
|
||||||
".md": "markdown_document",
|
".md": "markdown_document",
|
||||||
".json": "json_structure",
|
".json": "json_structure",
|
||||||
|
|
@ -66,7 +61,6 @@ ARCHETYPE_MAP = {
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FileContainer:
|
class FileContainer:
|
||||||
"""Represents a text-based information container."""
|
|
||||||
filepath: Path
|
filepath: Path
|
||||||
ext: str
|
ext: str
|
||||||
size: int
|
size: int
|
||||||
|
|
@ -75,50 +69,53 @@ class FileContainer:
|
||||||
|
|
||||||
|
|
||||||
def should_skip(filepath: Path) -> Tuple[bool, str]:
|
def should_skip(filepath: Path) -> Tuple[bool, str]:
|
||||||
"""Check if file should be skipped."""
|
|
||||||
path_str = str(filepath)
|
path_str = str(filepath)
|
||||||
|
|
||||||
# Skip 4-Infrastructure/config/metadata files
|
|
||||||
config_files = {
|
config_files = {
|
||||||
"package.json", "package-lock.json", "tsconfig.json", "devcontainer.json",
|
"package.json",
|
||||||
"settings.json", "launch.json", "tasks.json", ".stylelintrc.json",
|
"package-lock.json",
|
||||||
"biome.json", "lake-manifest.json", "pyrightconfig.json",
|
"tsconfig.json",
|
||||||
"manifest.json", "requirements.txt", "app.json", "acme.json",
|
"devcontainer.json",
|
||||||
".vscode", ".devcontainer"
|
"settings.json",
|
||||||
|
"launch.json",
|
||||||
|
"tasks.json",
|
||||||
|
".stylelintrc.json",
|
||||||
|
"biome.json",
|
||||||
|
"lake-manifest.json",
|
||||||
|
"pyrightconfig.json",
|
||||||
|
"manifest.json",
|
||||||
|
"requirements.txt",
|
||||||
|
"app.json",
|
||||||
|
"acme.json",
|
||||||
|
".vscode",
|
||||||
|
".devcontainer",
|
||||||
}
|
}
|
||||||
|
|
||||||
if filepath.name in config_files:
|
if filepath.name in config_files:
|
||||||
return True, "config"
|
return True, "config"
|
||||||
|
|
||||||
# Skip excluded paths
|
|
||||||
for pattern in EXCLUDE_PATTERNS:
|
for pattern in EXCLUDE_PATTERNS:
|
||||||
if pattern in path_str:
|
if pattern in path_str:
|
||||||
return True, f"excluded:{pattern}"
|
return True, f"excluded:{pattern}"
|
||||||
|
|
||||||
# Skip large binary-like files
|
if filepath.stat().st_size > 100_000_000:
|
||||||
if filepath.stat().st_size > 100_000_000: # > 100MB
|
|
||||||
return True, "too_large"
|
return True, "too_large"
|
||||||
|
|
||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
def compute_sha256(filepath: Path) -> str:
|
def compute_sha256(filepath: Path) -> str:
|
||||||
"""Compute SHA256 hash of file."""
|
|
||||||
sha256 = hashlib.sha256()
|
sha256 = hashlib.sha256()
|
||||||
try:
|
with open(filepath, "rb") as f:
|
||||||
with open(filepath, "rb") as f:
|
for chunk in iter(lambda: f.read(8192), b""):
|
||||||
for chunk in iter(lambda: f.read(8192), b""):
|
sha256.update(chunk)
|
||||||
sha256.update(chunk)
|
return f"sha256:{sha256.hexdigest()}"
|
||||||
return f"sha256:{sha256.hexdigest()}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"sha256:error:{str(e)}"
|
|
||||||
|
|
||||||
|
|
||||||
def infer_domain(filepath: Path, content: str = "") -> str:
|
def infer_domain(filepath: Path, content: str = "") -> str:
|
||||||
"""Infer domain from filename and path."""
|
|
||||||
name = filepath.stem.upper()
|
name = filepath.stem.upper()
|
||||||
path = str(filepath).upper()
|
path = str(filepath).upper()
|
||||||
|
|
||||||
domain_hints = {
|
domain_hints = {
|
||||||
"LEAN|SEMANTIC|FORMALIZATION": "formalization",
|
"LEAN|SEMANTIC|FORMALIZATION": "formalization",
|
||||||
"MATH|EQUATION|MODEL": "mathematics",
|
"MATH|EQUATION|MODEL": "mathematics",
|
||||||
|
|
@ -132,38 +129,34 @@ def infer_domain(filepath: Path, content: str = "") -> str:
|
||||||
"DATA|DATASET|CSV|TSV": "data_science",
|
"DATA|DATASET|CSV|TSV": "data_science",
|
||||||
"CONFIG|SETTING": "infrastructure",
|
"CONFIG|SETTING": "infrastructure",
|
||||||
}
|
}
|
||||||
|
|
||||||
for patterns, domain in domain_hints.items():
|
for patterns, domain in domain_hints.items():
|
||||||
if any(p in name or p in path for p in patterns.split("|")):
|
if any(p in name or p in path for p in patterns.split("|")):
|
||||||
return domain
|
return domain
|
||||||
|
|
||||||
# Default based on extension
|
if filepath.suffix in {".csv", ".tsv"}:
|
||||||
if filepath.suffix == ".csv" or filepath.suffix == ".tsv":
|
|
||||||
return "data_science"
|
return "data_science"
|
||||||
elif filepath.suffix == ".json":
|
if filepath.suffix == ".json":
|
||||||
return "specification"
|
return "specification"
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
def infer_tier(filepath: Path) -> str:
|
def infer_tier(filepath: Path) -> str:
|
||||||
"""Infer tier from path."""
|
|
||||||
path = str(filepath).lower()
|
path = str(filepath).lower()
|
||||||
if "6-Documentation/docs/semantics" in path or "docs" in path:
|
if "6-Documentation/docs/semantics" in path or "docs" in path:
|
||||||
return "CORE"
|
return "CORE"
|
||||||
elif "shared-data/data/germane" in path:
|
if "shared-data/data/germane" in path:
|
||||||
return "AUX"
|
return "AUX"
|
||||||
elif "out" in path:
|
if "out" in path:
|
||||||
return "DERIVED"
|
return "DERIVED"
|
||||||
return "AUX"
|
return "AUX"
|
||||||
|
|
||||||
|
|
||||||
def read_text_safely(filepath: Path, max_size: int = 1_000_000) -> str:
|
def read_text_safely(filepath: Path, max_size: int = 1_000_000) -> str:
|
||||||
"""Read text file safely with encoding fallback."""
|
|
||||||
encodings = ["utf-8", "utf-8-sig", "latin-1", "ascii", "cp1252"]
|
encodings = ["utf-8", "utf-8-sig", "latin-1", "ascii", "cp1252"]
|
||||||
|
|
||||||
size = filepath.stat().st_size
|
size = filepath.stat().st_size
|
||||||
if size > max_size:
|
if size > max_size:
|
||||||
# Read first and last chunks
|
|
||||||
with open(filepath, "rb") as f:
|
with open(filepath, "rb") as f:
|
||||||
start = f.read(500)
|
start = f.read(500)
|
||||||
f.seek(max(0, size - 500))
|
f.seek(max(0, size - 500))
|
||||||
|
|
@ -172,275 +165,210 @@ def read_text_safely(filepath: Path, max_size: int = 1_000_000) -> str:
|
||||||
else:
|
else:
|
||||||
with open(filepath, "rb") as f:
|
with open(filepath, "rb") as f:
|
||||||
content_bytes = f.read()
|
content_bytes = f.read()
|
||||||
|
|
||||||
for encoding in encodings:
|
for encoding in encodings:
|
||||||
try:
|
try:
|
||||||
return content_bytes.decode(encoding, errors="replace")
|
return content_bytes.decode(encoding, errors="replace")
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return "<unable to decode>"
|
return "<unable to decode>"
|
||||||
|
|
||||||
|
|
||||||
def extract_summary(content: str, max_chars: int = 250) -> str:
|
def extract_summary(content: str, max_chars: int = 250) -> str:
|
||||||
"""Extract summary from content."""
|
|
||||||
lines = content.split("\n")
|
lines = content.split("\n")
|
||||||
summary_lines = []
|
summary_lines = []
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped and not stripped.startswith("#") and not stripped.startswith("{"):
|
if stripped and not stripped.startswith("#") and not stripped.startswith("{"):
|
||||||
summary_lines.append(stripped)
|
summary_lines.append(stripped)
|
||||||
if len(" ".join(summary_lines)) >= max_chars:
|
if len(" ".join(summary_lines)) >= max_chars:
|
||||||
break
|
break
|
||||||
|
|
||||||
summary = " ".join(summary_lines)[:max_chars]
|
summary = " ".join(summary_lines)[:max_chars]
|
||||||
return summary or "<empty or binary file>"
|
return summary or "<empty or binary file>"
|
||||||
|
|
||||||
|
|
||||||
def csv_to_dict_list(filepath: Path, limit_rows: int = 100) -> List[Dict]:
|
|
||||||
"""Read CSV file into list of dicts."""
|
|
||||||
try:
|
|
||||||
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
reader = csv.DictReader(f)
|
|
||||||
return list(islice(reader, limit_rows))
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def compute_genome(filepath: Path, content: str = "") -> Dict[str, int]:
|
def compute_genome(filepath: Path, content: str = "") -> Dict[str, int]:
|
||||||
"""Compute 6D genome signature."""
|
|
||||||
try:
|
try:
|
||||||
size = filepath.stat().st_size
|
size = filepath.stat().st_size
|
||||||
lines = len(content.split("\n")) if content else 10
|
lines = len(content.split("\n")) if content else 10
|
||||||
|
|
||||||
mu = (lines % 256) // 32
|
mu = (lines % 256) // 32
|
||||||
rho = min(7, (size // 1024) % 8)
|
rho = min(7, (size // 1024) % 8)
|
||||||
c = 4
|
c = 4
|
||||||
m = 4
|
m = 4
|
||||||
ne = min(7, len(filepath.stem) // 10)
|
ne = min(7, len(filepath.stem) // 10)
|
||||||
sig = 0 if filepath.suffix != ".json" else 3
|
sig = 0 if filepath.suffix != ".json" else 3
|
||||||
|
|
||||||
return {"mu": mu, "rho": rho, "c": c, "m": m, "ne": ne, "sig": sig}
|
return {"mu": mu, "rho": rho, "c": c, "m": m, "ne": ne, "sig": sig}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"mu": 0, "rho": 0, "c": 4, "m": 4, "ne": 0, "sig": 0}
|
return {"mu": 0, "rho": 0, "c": 4, "m": 4, "ne": 0, "sig": 0}
|
||||||
|
|
||||||
|
|
||||||
def text_to_jsonl_entry(filepath: Path, node_id: str = "qfox") -> Optional[Dict[str, Any]]:
|
def text_to_jsonl_entry(filepath: Path, node_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Convert a text container to JSON-L entry."""
|
should_skip_file, _reason = should_skip(filepath)
|
||||||
|
if should_skip_file:
|
||||||
try:
|
|
||||||
should_skip_file, reason = should_skip(filepath)
|
|
||||||
if should_skip_file:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Read content
|
|
||||||
content = read_text_safely(filepath)
|
|
||||||
file_hash = compute_sha256(filepath)
|
|
||||||
file_stat = filepath.stat()
|
|
||||||
mtime_unix = file_stat.st_mtime
|
|
||||||
file_size = file_stat.st_size
|
|
||||||
|
|
||||||
# Compute metadata
|
|
||||||
domain = infer_domain(filepath, content)
|
|
||||||
tier = infer_tier(filepath)
|
|
||||||
genome = compute_genome(filepath, content)
|
|
||||||
|
|
||||||
# Create pkg identifier
|
|
||||||
rel_path = filepath.relative_to(WORKSPACE_ROOT)
|
|
||||||
pkg = f"ene/text/{filepath.suffix[1:]}/{rel_path.stem}".replace("\\", "/")
|
|
||||||
version = datetime.fromtimestamp(mtime_unix, tz=timezone.utc).isoformat()
|
|
||||||
|
|
||||||
concept_anchor = {
|
|
||||||
"domain": domain,
|
|
||||||
"concept": filepath.stem.lower().replace(" ", "_").replace("-", "_").replace(".", "_"),
|
|
||||||
"resolution": "STABLE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract data payload based on file type
|
|
||||||
summary = extract_summary(content)
|
|
||||||
|
|
||||||
data_payload = {
|
|
||||||
"pkg": pkg,
|
|
||||||
"version": version,
|
|
||||||
"tier": tier,
|
|
||||||
"domain": domain,
|
|
||||||
"archetype": ARCHETYPE_MAP.get(filepath.suffix, "text_document"),
|
|
||||||
"concept_anchor": concept_anchor,
|
|
||||||
"file_path": str(rel_path).replace("\\", "/"),
|
|
||||||
"file_ext": filepath.suffix,
|
|
||||||
"file_hash": file_hash,
|
|
||||||
"byte_count": file_size,
|
|
||||||
"line_count": len(content.split("\n")),
|
|
||||||
"summary": summary,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add format-specific metadata
|
|
||||||
if filepath.suffix == ".json":
|
|
||||||
try:
|
|
||||||
obj = json.loads(content)
|
|
||||||
data_payload["json_keys"] = list(obj.keys() if isinstance(obj, dict) else [])
|
|
||||||
except Exception:
|
|
||||||
data_payload["json_keys"] = []
|
|
||||||
|
|
||||||
elif filepath.suffix in {".csv", ".tsv"}:
|
|
||||||
try:
|
|
||||||
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
reader = csv.DictReader(f)
|
|
||||||
first_row = next(reader, None)
|
|
||||||
if first_row:
|
|
||||||
data_payload["columns"] = list(first_row.keys())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Provenance
|
|
||||||
provenance = {
|
|
||||||
"node": node_id,
|
|
||||||
"lake_seed": "text_converter",
|
|
||||||
"tailscale_ip": "127.0.0.1",
|
|
||||||
"attestation_hash": file_hash,
|
|
||||||
"prev_id": None
|
|
||||||
}
|
|
||||||
|
|
||||||
# Bind
|
|
||||||
bind = {
|
|
||||||
"lawful": True,
|
|
||||||
"cost": 0x00010000,
|
|
||||||
"invariant": "documentConsistency",
|
|
||||||
"class": "informational_bind"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Full JSON-L entry
|
|
||||||
entry = {
|
|
||||||
"t": mtime_unix,
|
|
||||||
"src": "ene",
|
|
||||||
"id": f"ene:{pkg}:{version}",
|
|
||||||
"op": "upsert",
|
|
||||||
"data": data_payload,
|
|
||||||
"genome": genome,
|
|
||||||
"bind": bind,
|
|
||||||
"provenance": provenance
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠️ Error processing {filepath}: {e}", file=sys.stderr)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
content = read_text_safely(filepath)
|
||||||
|
file_hash = compute_sha256(filepath)
|
||||||
|
file_stat = filepath.stat()
|
||||||
|
mtime_unix = file_stat.st_mtime
|
||||||
|
file_size = file_stat.st_size
|
||||||
|
|
||||||
|
domain = infer_domain(filepath, content)
|
||||||
|
tier = infer_tier(filepath)
|
||||||
|
genome = compute_genome(filepath, content)
|
||||||
|
|
||||||
|
rel_path = filepath.relative_to(WORKSPACE_ROOT)
|
||||||
|
pkg = f"ene/text/{filepath.suffix[1:]}/{rel_path.stem}".replace("\\", "/")
|
||||||
|
version = datetime.fromtimestamp(mtime_unix, tz=timezone.utc).isoformat()
|
||||||
|
|
||||||
|
concept_anchor = {
|
||||||
|
"domain": domain,
|
||||||
|
"concept": filepath.stem.lower().replace(" ", "_").replace("-", "_").replace(".", "_"),
|
||||||
|
"resolution": "STABLE",
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = extract_summary(content)
|
||||||
|
|
||||||
|
data_payload: Dict[str, Any] = {
|
||||||
|
"pkg": pkg,
|
||||||
|
"version": version,
|
||||||
|
"tier": tier,
|
||||||
|
"domain": domain,
|
||||||
|
"archetype": ARCHETYPE_MAP.get(filepath.suffix, "text_document"),
|
||||||
|
"concept_anchor": concept_anchor,
|
||||||
|
"file_path": str(rel_path).replace("\\", "/"),
|
||||||
|
"file_ext": filepath.suffix,
|
||||||
|
"file_hash": file_hash,
|
||||||
|
"byte_count": file_size,
|
||||||
|
"line_count": len(content.split("\n")),
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
if filepath.suffix == ".json":
|
||||||
|
try:
|
||||||
|
obj = json.loads(content)
|
||||||
|
data_payload["json_keys"] = list(obj.keys() if isinstance(obj, dict) else [])
|
||||||
|
except Exception:
|
||||||
|
data_payload["json_keys"] = []
|
||||||
|
|
||||||
|
if filepath.suffix in {".csv", ".tsv"}:
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
first_row = next(reader, None)
|
||||||
|
if first_row:
|
||||||
|
data_payload["columns"] = list(first_row.keys())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
provenance = {
|
||||||
|
"node": node_id,
|
||||||
|
"lake_seed": env_default("ENE_LAKE_SEED", "text_converter"),
|
||||||
|
"tailscale_ip": env_default("ENE_TAILSCALE_IP", "127.0.0.1"),
|
||||||
|
"attestation_hash": file_hash,
|
||||||
|
"prev_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
bind = {
|
||||||
|
"lawful": True,
|
||||||
|
"cost": 0x00010000,
|
||||||
|
"invariant": "documentConsistency",
|
||||||
|
"class": "informational_bind",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"t": mtime_unix,
|
||||||
|
"src": "ene",
|
||||||
|
"id": f"ene:{pkg}:{version}",
|
||||||
|
"op": "upsert",
|
||||||
|
"data": data_payload,
|
||||||
|
"genome": genome,
|
||||||
|
"bind": bind,
|
||||||
|
"provenance": provenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def find_all_text_containers() -> List[Path]:
|
def find_all_text_containers() -> List[Path]:
|
||||||
"""Find all text containers in workspace."""
|
|
||||||
containers = []
|
containers = []
|
||||||
|
|
||||||
for ext in PROCESS_EXTENSIONS:
|
for ext in PROCESS_EXTENSIONS:
|
||||||
for file_path in WORKSPACE_ROOT.rglob(f"*{ext}"):
|
for file_path in WORKSPACE_ROOT.rglob(f"*{ext}"):
|
||||||
if file_path.is_file():
|
if file_path.is_file():
|
||||||
containers.append(file_path)
|
containers.append(file_path)
|
||||||
|
|
||||||
return sorted(list(set(containers)))
|
return sorted(list(set(containers)))
|
||||||
|
|
||||||
|
|
||||||
def load_existing_manifest() -> set:
|
def load_existing_manifest() -> set:
|
||||||
"""Load IDs from existing manifest."""
|
|
||||||
existing_ids = set()
|
existing_ids = set()
|
||||||
if MANIFEST_PATH.exists():
|
if MANIFEST_PATH.exists():
|
||||||
try:
|
with open(MANIFEST_PATH, "r") as f:
|
||||||
with open(MANIFEST_PATH, "r") as f:
|
for line in f:
|
||||||
for line in f:
|
line = line.strip()
|
||||||
line = line.strip()
|
if not line:
|
||||||
if line:
|
continue
|
||||||
try:
|
try:
|
||||||
entry = json.loads(line)
|
entry = json.loads(line)
|
||||||
existing_ids.add(entry.get("id", ""))
|
existing_ids.add(entry.get("id", ""))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
continue
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Could not read existing manifest: {e}")
|
|
||||||
return existing_ids
|
return existing_ids
|
||||||
|
|
||||||
|
|
||||||
def convert_all_text_containers(output_file: Optional[str] = None) -> Tuple[int, int, int, int]:
|
def convert_all_text_containers(node_id: str, output_file: Optional[str] = None) -> Tuple[int, int, int, int]:
|
||||||
"""
|
|
||||||
Convert all text containers to JSON-L.
|
|
||||||
|
|
||||||
Returns: (total_files, newly_converted, already_exists, skipped)
|
|
||||||
"""
|
|
||||||
from itertools import islice
|
|
||||||
|
|
||||||
output_path = Path(output_file) if output_file else MANIFEST_PATH
|
output_path = Path(output_file) if output_file else MANIFEST_PATH
|
||||||
|
|
||||||
print(f"🔍 Scanning for text containers...")
|
|
||||||
containers = find_all_text_containers()
|
containers = find_all_text_containers()
|
||||||
print(f"✅ Found {len(containers)} text containers")
|
|
||||||
|
|
||||||
existing_ids = load_existing_manifest()
|
existing_ids = load_existing_manifest()
|
||||||
print(f"📋 Manifest already has {len(existing_ids)} entries")
|
|
||||||
print()
|
|
||||||
|
|
||||||
converted = 0
|
converted = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
already_exists = 0
|
already_exists = 0
|
||||||
|
|
||||||
ext_stats = {}
|
|
||||||
|
|
||||||
with open(output_path, "a") as manifest_f:
|
with open(output_path, "a") as manifest_f:
|
||||||
for i, container in enumerate(containers, 1):
|
for container in containers:
|
||||||
ext = container.suffix
|
entry = text_to_jsonl_entry(container, node_id=node_id)
|
||||||
ext_stats[ext] = ext_stats.get(ext, 0) + 1
|
|
||||||
|
|
||||||
entry = text_to_jsonl_entry(container)
|
|
||||||
|
|
||||||
if entry is None:
|
if entry is None:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
status = "⏭️ SKIP"
|
continue
|
||||||
|
|
||||||
|
entry_id = entry.get("id", "")
|
||||||
|
if entry_id in existing_ids:
|
||||||
|
already_exists += 1
|
||||||
else:
|
else:
|
||||||
entry_id = entry.get("id", "")
|
manifest_f.write(json.dumps(entry) + "\n")
|
||||||
if entry_id in existing_ids:
|
manifest_f.flush()
|
||||||
already_exists += 1
|
converted += 1
|
||||||
status = "⏪ DUP"
|
|
||||||
else:
|
|
||||||
manifest_f.write(json.dumps(entry) + "\n")
|
|
||||||
manifest_f.flush()
|
|
||||||
converted += 1
|
|
||||||
status = "✅ CONV"
|
|
||||||
|
|
||||||
rel_path = container.relative_to(WORKSPACE_ROOT)
|
|
||||||
|
|
||||||
# Less verbose output
|
|
||||||
if i % 10 == 0 or status == "✅ CONV":
|
|
||||||
print(f"[{i:4d}/{len(containers)}] {status} {rel_path}")
|
|
||||||
|
|
||||||
return len(containers), converted, already_exists, skipped
|
return len(containers), converted, already_exists, skipped
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> int:
|
||||||
"""Main entry point."""
|
parser = argparse.ArgumentParser()
|
||||||
output_file = None
|
parser.add_argument("--output-file", default=None)
|
||||||
if len(sys.argv) > 2 and sys.argv[1] == "--output-file":
|
parser.add_argument("--node-id", default=env_default("ENE_NODE_ID", "qfox"))
|
||||||
output_file = sys.argv[2]
|
args = parser.parse_args()
|
||||||
|
|
||||||
print("=" * 75)
|
total, converted, already_exists, skipped = convert_all_text_containers(
|
||||||
print("🌐 Comprehensive Text Container to JSON-L Converter")
|
node_id=args.node_id, output_file=args.output_file
|
||||||
print(f"Workspace: {WORKSPACE_ROOT}")
|
)
|
||||||
print(f"Output: {output_file or MANIFEST_PATH}")
|
print(
|
||||||
print("=" * 75)
|
json.dumps(
|
||||||
print()
|
{
|
||||||
|
"total": total,
|
||||||
total, converted, already_exists, skipped = convert_all_text_containers(output_file)
|
"converted": converted,
|
||||||
|
"already_exists": already_exists,
|
||||||
print()
|
"skipped": skipped,
|
||||||
print("=" * 75)
|
"node_id": args.node_id,
|
||||||
print(f"📊 Summary:")
|
},
|
||||||
print(f" Total files scanned: {total}")
|
indent=2,
|
||||||
print(f" Newly converted: {converted}")
|
)
|
||||||
print(f" Already in manifest: {already_exists}")
|
)
|
||||||
print(f" Skipped: {skipped}")
|
return 0
|
||||||
print(f" Output file: {output_file or MANIFEST_PATH}")
|
|
||||||
print("=" * 75)
|
|
||||||
|
|
||||||
return 0 if converted > 0 else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
raise SystemExit(main())
|
||||||
|
|
|
||||||
134
6-Documentation/docs/LEAN_FIRST_BOUNDARY.md
Normal file
134
6-Documentation/docs/LEAN_FIRST_BOUNDARY.md
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
# LEAN_FIRST_BOUNDARY — Lean-first compliance contract
|
||||||
|
|
||||||
|
**Status:** Canonical enforcement doc
|
||||||
|
|
||||||
|
> **Lean is the source of truth. Everything else is a shim.**
|
||||||
|
|
||||||
|
This document is the short, contributor-facing boundary contract referenced by
|
||||||
|
AGENTS.md and all architecture/spec files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Definitions
|
||||||
|
|
||||||
|
### 1.1 Canonical
|
||||||
|
|
||||||
|
"Canonical" means:
|
||||||
|
|
||||||
|
- the semantics are defined in Lean under `0-Core-Formalism/lean/Semantics/`
|
||||||
|
- `lake build` is the authority gate for formal claims
|
||||||
|
- invariants/cost functions/typed residual policies are Lean-owned
|
||||||
|
|
||||||
|
### 1.2 Shim / adapter
|
||||||
|
|
||||||
|
"Shim" means:
|
||||||
|
|
||||||
|
- boundary-only code in Python/Rust/JS/etc.
|
||||||
|
- allowed to do I/O, parsing, serialization, DB connections, subprocess spawn
|
||||||
|
- forbidden to define semantics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) What belongs in Lean (required)
|
||||||
|
|
||||||
|
Lean MUST own:
|
||||||
|
|
||||||
|
1. **Finite types / enumerations**
|
||||||
|
- no open string matching in the core
|
||||||
|
- strings are boundary I/O only
|
||||||
|
|
||||||
|
2. **Invariants + cost functions**
|
||||||
|
- any logic that decides ACCEPT/REJECT/QUARANTINE/PROMOTE
|
||||||
|
|
||||||
|
3. **Provenance + attestation policy**
|
||||||
|
- required provenance fields
|
||||||
|
- deterministic hashing / chain rules
|
||||||
|
- receipt typing
|
||||||
|
|
||||||
|
4. **Surface/tool manifests**
|
||||||
|
- the list of supported tool names for an MCP surface
|
||||||
|
- the mapping from tool name → schema/behavior class
|
||||||
|
|
||||||
|
5. **Validators**
|
||||||
|
- any configuration payload (yaml/json/env inputs) must be validated by Lean
|
||||||
|
- missing required fields must be rejected or typed as residuals
|
||||||
|
- never silently default “unknown” into a guessed value
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) What belongs in shims (allowed)
|
||||||
|
|
||||||
|
Shims MAY do:
|
||||||
|
|
||||||
|
- read env vars / config files
|
||||||
|
- JSON/YAML parsing
|
||||||
|
- open sqlite/postgres connections
|
||||||
|
- call subprocesses (including Lean executables)
|
||||||
|
- transport: HTTP/MCP stdio plumbing
|
||||||
|
- emit logs
|
||||||
|
|
||||||
|
Shims MUST NOT:
|
||||||
|
|
||||||
|
- compute or approximate a cost function
|
||||||
|
- decide lawfulness
|
||||||
|
- guess missing fields (no “conservative defaults”)
|
||||||
|
- introduce new invariants
|
||||||
|
- implement branching that changes semantics (routing must be driven by Lean-owned tags/enums)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Float prohibition summary
|
||||||
|
|
||||||
|
- New core logic must not use `Float`.
|
||||||
|
- Use fixed-point:
|
||||||
|
- Q0_16 for dimensionless scalars
|
||||||
|
- Q16_16 only when range/precision is proven necessary
|
||||||
|
|
||||||
|
If a shim accepts float input from the outside world, it must convert at the
|
||||||
|
boundary and pass fixed-point values onward.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Receipts vs rejection
|
||||||
|
|
||||||
|
When adapter input is malformed or underspecified:
|
||||||
|
|
||||||
|
- **Preferred:** reject with a typed reason (Lean)
|
||||||
|
- **Alternative (when required):** accept as a *workbench_projection* artifact
|
||||||
|
but emit an explicit receipt describing:
|
||||||
|
- what was missing
|
||||||
|
- what was assumed
|
||||||
|
- what must be repaired to become receipt_backed
|
||||||
|
|
||||||
|
No silent repair.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Quick examples
|
||||||
|
|
||||||
|
### 6.1 BAD shim behavior
|
||||||
|
|
||||||
|
- A Python script reads `nodes.yaml`, sees missing CPU/RAM, and fills defaults.
|
||||||
|
- A Rust MCP server hardcodes tool names as strings.
|
||||||
|
|
||||||
|
### 6.2 GOOD shim behavior
|
||||||
|
|
||||||
|
- A shim reads `nodes.yaml`, passes JSON to a Lean validator, and exits nonzero
|
||||||
|
with a Lean-produced error if required fields are missing.
|
||||||
|
- A shim queries Lean for the canonical MCP tool manifest and exposes exactly
|
||||||
|
those tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Enforcement
|
||||||
|
|
||||||
|
- Any PR that introduces semantics in non-Lean code is an invariant violation.
|
||||||
|
- Any PR that adds new dependencies without explicit approval is rejected.
|
||||||
|
- Any PR that adds open-ended string parsing in the core is rejected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) References
|
||||||
|
|
||||||
|
- `6-Documentation/docs/AGENTS.md`
|
||||||
|
- `0-Core-Formalism/lean/Semantics/Semantics/JsonLSurfaceConnector.lean`
|
||||||
|
|
@ -20,6 +20,110 @@ receipt_density populated == route has structural/witness evidence for RRC use
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 2.1 verification sequence
|
||||||
|
|
||||||
|
Run the local regression harness first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 4-Infrastructure/shim/test_pist_receipt_density_injector.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected result: all tests print `PASS`.
|
||||||
|
|
||||||
|
Then emit the audit JSON only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 4-Infrastructure/shim/pist_receipt_density_injector.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
```text
|
||||||
|
shared-data/rrc_receipt_density_backfill.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Only after inspection, run the guarded sidecar write:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 4-Infrastructure/shim/pist_receipt_density_injector.py --write-rds
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally validate the sidecar table:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 4-Infrastructure/shim/validate_receipt_density_sidecar.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional validation report:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 4-Infrastructure/shim/validate_receipt_density_sidecar.py \
|
||||||
|
--out shared-data/rrc_receipt_density_sidecar_validation.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The validator fails if:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sidecar table is empty
|
||||||
|
any row has promotion != not_promoted
|
||||||
|
any density/confidence is null or outside [0, 1]
|
||||||
|
any receipt hash/source is missing
|
||||||
|
any receipt_density_status is not CANDIDATE or HOLD
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2.2 shape-alignment calibration
|
||||||
|
|
||||||
|
After classifier-backed density has been generated, run the alignment pass:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 4-Infrastructure/shim/rrc_pist_shape_alignment.py \
|
||||||
|
--fail-on-raw-disagreement
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inspect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jq '.summary.shape_alignment_counts, .summary.warning_counts' \
|
||||||
|
shared-data/rrc_receipt_density_backfill.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected transition for the current corpus:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pist_shape_disagreement -> removed
|
||||||
|
structural_semantic_label_divergence -> present for compatible PIST/RRC label-space divergence
|
||||||
|
shape_alignment_counts.compatible_structural_projection -> most or all records
|
||||||
|
```
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PIST exact/proxy label = structural/spectral morphology
|
||||||
|
RRC shape label = semantic/domain routing class
|
||||||
|
```
|
||||||
|
|
||||||
|
So a row like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PIST exact label: LogogramProjection
|
||||||
|
RRC shape: CognitiveLoadField
|
||||||
|
```
|
||||||
|
|
||||||
|
is not necessarily an error. It can mean PIST sees a symbolic/logogram surface while RRC supplies the semantic routing class.
|
||||||
|
|
||||||
|
After alignment, rerun the guarded RDS sidecar write and readback validator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 4-Infrastructure/shim/pist_receipt_density_injector.py --write-rds
|
||||||
|
python3 4-Infrastructure/shim/validate_receipt_density_sidecar.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit the aligned JSON artifacts after validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Default inputs
|
## Default inputs
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -81,6 +185,29 @@ python3 4-Infrastructure/shim/pist_receipt_density_injector.py \
|
||||||
--out shared-data/rrc_receipt_density_backfill.json
|
--out shared-data/rrc_receipt_density_backfill.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Guarded RDS sidecar write:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 4-Infrastructure/shim/pist_receipt_density_injector.py \
|
||||||
|
--write-rds \
|
||||||
|
--rds-table ene.rrc_receipt_density
|
||||||
|
```
|
||||||
|
|
||||||
|
The writer uses:
|
||||||
|
|
||||||
|
```text
|
||||||
|
4-Infrastructure/shim/rds_connect.py
|
||||||
|
```
|
||||||
|
|
||||||
|
so connection parameters resolve through the shared RDS path:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DATABASE_URL
|
||||||
|
RDS_* env vars
|
||||||
|
IAM token / boto3 / AWS CLI
|
||||||
|
password fallback
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Record schema
|
## Record schema
|
||||||
|
|
@ -112,17 +239,62 @@ Each record has the form:
|
||||||
"rank_estimate": 8,
|
"rank_estimate": 8,
|
||||||
"laplacian_zero_count": 1
|
"laplacian_zero_count": 1
|
||||||
},
|
},
|
||||||
|
"shape_alignment": {
|
||||||
|
"alignment_version": "rrc-pist-shape-alignment-v1",
|
||||||
|
"alignment_status": "compatible_structural_projection",
|
||||||
|
"alignment_confidence": 0.72,
|
||||||
|
"label_space_model": "PIST=morphology; RRC=semantic routing"
|
||||||
|
},
|
||||||
"top_axes": ["projection_declared", "negative_control_strength"],
|
"top_axes": ["projection_declared", "negative_control_strength"],
|
||||||
"status": "CANDIDATE",
|
"status": "CANDIDATE",
|
||||||
"promotion": "not_promoted",
|
"promotion": "not_promoted",
|
||||||
"source": "pist_receipt_density_injector_v1",
|
"source": "pist_receipt_density_injector_v1",
|
||||||
"receipt_hash": "...",
|
"receipt_hash": "...",
|
||||||
"warnings": []
|
"warnings": ["structural_semantic_label_divergence"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## RDS sidecar schema
|
||||||
|
|
||||||
|
The guarded writer creates or upserts into:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ene.rrc_receipt_density
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
```text
|
||||||
|
equation_id
|
||||||
|
rrc_shape
|
||||||
|
domain
|
||||||
|
source_status
|
||||||
|
receipt_density
|
||||||
|
receipt_density_source
|
||||||
|
receipt_density_hash
|
||||||
|
receipt_density_status
|
||||||
|
receipt_density_warnings
|
||||||
|
confidence
|
||||||
|
top_axes
|
||||||
|
shape_prediction
|
||||||
|
density_components
|
||||||
|
promotion
|
||||||
|
payload
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
The table has a check constraint:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
promotion = 'not_promoted'
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes promotion drift fail at the database boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Density calculation
|
## Density calculation
|
||||||
|
|
||||||
The density score is computed from four bounded components:
|
The density score is computed from four bounded components:
|
||||||
|
|
@ -186,25 +358,17 @@ Promotion still requires external receipts, Lean/kernel verification where appli
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Next integration step
|
## CI candidate
|
||||||
|
|
||||||
Once the JSON output is inspected, the next safe step is an explicit DB writer guarded by a flag such as:
|
A minimal CI step should run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
--write-rds
|
python3 4-Infrastructure/shim/test_pist_receipt_density_injector.py
|
||||||
|
python3 4-Infrastructure/shim/pist_receipt_density_injector.py --fail-on-missing-pist
|
||||||
|
python3 4-Infrastructure/shim/rrc_pist_shape_alignment.py --fail-on-raw-disagreement
|
||||||
```
|
```
|
||||||
|
|
||||||
That writer should upsert only these fields:
|
The sidecar validator should run only in environments with RDS credentials.
|
||||||
|
|
||||||
```text
|
|
||||||
receipt_density
|
|
||||||
receipt_density_source
|
|
||||||
receipt_density_hash
|
|
||||||
receipt_density_status
|
|
||||||
receipt_density_warnings
|
|
||||||
```
|
|
||||||
|
|
||||||
and should not alter theorem truth, promotion state, or claim ladder status.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -213,3 +377,7 @@ and should not alter theorem truth, promotion state, or claim ladder status.
|
||||||
```text
|
```text
|
||||||
PIST stops being just a repair engine when its classifications become receipt density for the RRC corpus.
|
PIST stops being just a repair engine when its classifications become receipt density for the RRC corpus.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
PIST is seeing morphology; RRC is naming semantics.
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,183 +1,211 @@
|
||||||
# Canonical Specification for the Adaptive Virtual Machine (AVM)
|
# Canonical Specification for the Adaptive Virtual Machine (AVM)
|
||||||
## State: CALIBRATED_PENDING_VALIDATION
|
## State: CALIBRATED_PENDING_VALIDATION
|
||||||
|
|
||||||
|
This document is the canonical design for **AVM as a Lean-defined core ISA**.
|
||||||
|
|
||||||
## 1. AVM Instruction Set Architecture (ISA)
|
**Core rule:**
|
||||||
|
|
||||||
The AVM ISA defines a language-agnostic instruction set that serves as the bridge between mathematical languages and Python execution. The ISA consists of:
|
> **Lean is the source of truth. AVM is a Lean-only ISA.**
|
||||||
|
|
||||||
### Core Instruction Set
|
All non-Lean languages (Python, Rust, C/C++, Go, etc.) are **adapter shims** (a.k.a.
|
||||||
```python
|
backends) that *strip / serialize / reinterpret* into AVM programs and execute
|
||||||
# Stack-based operations
|
AVM semantics. They do not define new semantics.
|
||||||
PUSH(value: Any) # Push a value onto the stack
|
|
||||||
POP() # Pop the top value from the stack
|
|
||||||
APPLY(func: Any) # Apply a function to the top N arguments
|
|
||||||
JUMP(label: int) # Unconditional jump
|
|
||||||
JUMP_IF(condition: bool, label: int) # Conditional jump
|
|
||||||
CALL(method: str) # Call a Python method
|
|
||||||
IMPORT(module: str) # Import a Python module
|
|
||||||
RETURN() # Return from function
|
|
||||||
|
|
||||||
# Data operations
|
---
|
||||||
STORE(name: str) # Store a value in the symbol table
|
|
||||||
LOAD(name: str) # Load a value from the symbol table
|
|
||||||
DUMP() # Dump execution state for debugging
|
|
||||||
|
|
||||||
# Control flow
|
## 0. Definitions
|
||||||
BEGIN(label: int) # Mark a label
|
|
||||||
END() # End of function
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Representation
|
### 0.1 AVM Core
|
||||||
- **Values**: All values are represented as Python-compatible objects or fixed-point atoms
|
The **AVM core** is the ISA + operational semantics defined in Lean.
|
||||||
- **Types**: Minimal type system (int, Q16_16, Q0_16, bool, list, dict, function)
|
|
||||||
- **Constants**: Predefined constants for math operations (π, e, etc.) expressed in Q16_16
|
|
||||||
|
|
||||||
### Execution Model
|
- Finite opcode set (closed-world)
|
||||||
- Stack-based virtual machine
|
- Finite value type set (closed-world)
|
||||||
- Type-checking during execution
|
- Deterministic step/run semantics
|
||||||
- Error handling via Python exceptions
|
- No open string matching in decisions
|
||||||
- Symbol table for cross-language data sharing
|
- No dynamic "Any" values in the ISA
|
||||||
|
|
||||||
## 2. Semantic Stripping Algorithm
|
### 0.2 Adapter shims (backends)
|
||||||
|
Adapter shims are **extraction/interop targets**, not sources of truth.
|
||||||
|
|
||||||
```python
|
They may:
|
||||||
def strip_semantics(source: Any, threshold: float = 0.5) -> Any:
|
- Encode/decode AVM programs and values (serialization)
|
||||||
"""
|
- Interpret AVM programs (runtime interpreter)
|
||||||
Strips language-specific semantics while preserving functionality
|
- Emit target artifacts (Python bytecode, C, Rust, Verilog, FPGA netlists)
|
||||||
"""
|
|
||||||
if is_invariant_root(source): # Check if already a fundamental structure
|
|
||||||
return source
|
|
||||||
|
|
||||||
delta = calculate_delta(source) # 0-1 score of language dependency
|
They may **not**:
|
||||||
|
- Introduce new ISA meaning
|
||||||
if delta < threshold:
|
- Add ad-hoc branching policy
|
||||||
# Decompose into invariant components
|
- Decide invariants or costs outside Lean
|
||||||
components = decompose(source)
|
|
||||||
stripped_components = [strip_semantics(c, threshold) for c in components]
|
|
||||||
return reconstruct(stripped_components)
|
|
||||||
else:
|
|
||||||
# Preserve as invariant root
|
|
||||||
return source
|
|
||||||
|
|
||||||
def calculate_delta(node: Any) -> float:
|
---
|
||||||
"""
|
|
||||||
Computes language dependency score (0-1)
|
|
||||||
- 0: Pure invariant structure
|
|
||||||
- 1: Heavily language-specific
|
|
||||||
"""
|
|
||||||
# Implementation would analyze type signatures, syntax, etc.
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. AVM Binary Interface (ABI)
|
## 1. AVM Instruction Set Architecture (ISA) — Lean-only
|
||||||
|
|
||||||
### Calling Convention
|
The AVM ISA is a **Lean inductive** instruction set.
|
||||||
- **Stack layout**: CPython-compatible stack layout
|
|
||||||
- **Argument passing**: All arguments pushed in order
|
|
||||||
- **Return value**: Single value on stack
|
|
||||||
- **Error handling**: Python exceptions
|
|
||||||
|
|
||||||
### Data Format
|
### 1.1 Closed-world opcodes
|
||||||
```python
|
The opcode set MUST be finite and enumerable.
|
||||||
class AVMValue:
|
|
||||||
def __init__(self, value: Any):
|
|
||||||
self.value = value
|
|
||||||
self.type = get_type(value)
|
|
||||||
|
|
||||||
def get_type(value: Any) -> str:
|
A minimal core (illustrative, not final):
|
||||||
"""
|
|
||||||
Maps to CPython's internal type representation
|
|
||||||
"""
|
|
||||||
if isinstance(value, int): return "int"
|
|
||||||
if hasattr(value, 'is_q16_16'): return "Q16_16"
|
|
||||||
if hasattr(value, 'is_q0_16'): return "Q0_16"
|
|
||||||
# ... other types
|
|
||||||
```
|
|
||||||
|
|
||||||
### Registration Protocol
|
- Stack ops: `push`, `pop`, `dup`, `swap`
|
||||||
```python
|
- Locals: `load`, `store` (indexed by `Fin n`)
|
||||||
def register_primitive(name: str, func: Callable):
|
- Control flow: `jump`, `jumpIf`, `halt`
|
||||||
"""
|
- Fixed-point arithmetic primitives: `addSat`, `subSat`, `mul`, etc.
|
||||||
Registers a primitive function for direct AVM execution
|
|
||||||
"""
|
|
||||||
_registry[name] = func
|
|
||||||
|
|
||||||
_registry = {}
|
### 1.2 Strict typing
|
||||||
```
|
The ISA operates over a finite type universe:
|
||||||
|
|
||||||
## 4. Invariant Root Extraction Process
|
- `Q0_16` (default for dimensionless scalars)
|
||||||
|
- `Q16_16` (only when range/precision forces it)
|
||||||
|
- `Bool`
|
||||||
|
- (Optional later) `UInt8`, `UInt16`, `UInt32`, fixed-width words for IO/register surfaces
|
||||||
|
|
||||||
```python
|
### 1.3 Float prohibition (strong)
|
||||||
def extract_invariants(source: Any) -> List[Any]:
|
**Float must not be used anywhere if at all possible.**
|
||||||
"""
|
|
||||||
Recursively extracts fundamental mathematical structures
|
|
||||||
"""
|
|
||||||
if is_primitive(source): return [source]
|
|
||||||
|
|
||||||
if is_container(source):
|
|
||||||
children = []
|
|
||||||
for child in source.children:
|
|
||||||
children.extend(extract_invariants(child))
|
|
||||||
return children
|
|
||||||
|
|
||||||
raise ValueError(f"Unsupported type: {type(source)}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Formal Specification in Lean 4
|
- **AVM core**: Float is forbidden.
|
||||||
|
- **Backends**: Float is forbidden for any semantic computation.
|
||||||
|
- **Boundary-only exception**: A backend may accept Float *only* at an external I/O boundary (JSON, sensor ingest, UI display), and must immediately convert it to `Q0_16` or `Q16_16`.
|
||||||
|
|
||||||
namespace Semantics.AVM
|
If Float appears anywhere, it must carry an explicit justification comment/receipt field:
|
||||||
|
|
||||||
inductive Value where
|
- why fixed-point abstraction was not possible
|
||||||
| int : Int → Value
|
- what exact conversion policy was used (clamp/range/rounding)
|
||||||
| q16 : Q16_16 → Value
|
- what determinism guarantee remains
|
||||||
| q0 : Q0_16 → Value
|
|
||||||
| bool : Bool → Value
|
|
||||||
|
|
||||||
instance : informational_bind State Value where
|
Default is: **reject**.
|
||||||
isLawful s := true
|
|
||||||
cost s := 0 -- Base transition cost
|
|
||||||
extract s := "AVM_STATE"
|
|
||||||
|
|
||||||
def compile (source : SourceLang) : Value :=
|
### 1.4 No dynamic foreign calls in ISA
|
||||||
match source with
|
The ISA must not contain opcodes like `CALL("pythonMethod")` or `IMPORT("module")`.
|
||||||
| `int n => Value.int n
|
|
||||||
| `fixed n => Value.q16 n
|
|
||||||
| `ratio n => Value.q0 n
|
|
||||||
| `bool b => Value.bool b
|
|
||||||
```
|
|
||||||
|
|
||||||
This specification provides a foundation for proving equivalence between source language semantics and AVM execution. The full implementation would include:
|
If extensibility is needed, it must be via **finite enums** (e.g. `Prim : Type`)
|
||||||
|
with semantics defined in Lean:
|
||||||
|
|
||||||
1. Type soundness proof
|
- `Prim` is finite
|
||||||
2. Preservation theorem
|
- `evalPrim : Prim -> ...` is defined in Lean
|
||||||
3. Progress theorem
|
- backends implement `Prim` by matching the Lean semantics
|
||||||
4. Adequacy proof
|
|
||||||
|
|
||||||
## 6. CALIBRATED State Requirements
|
---
|
||||||
The AVM is considered **CALIBRATED** once the following conditions are met:
|
|
||||||
|
|
||||||
1. **Float Elimination**: All core primitive float references have been removed from the AVM ISA and Lean core.
|
## 2. Execution model
|
||||||
2. **Type Definition**: `Q0_16` and `Q16_16` are explicitly defined and used as the primary numeric types.
|
|
||||||
3. **Behavioral Declaration**: Arithmetic, rounding (floor), and overflow (saturating) behaviors are formally declared and implemented.
|
|
||||||
4. **Boundary Conversion**: Explicit paths for converting external numeric formats (Int, Float-input) into `Q16_16` are defined.
|
|
||||||
5. **Determinism Invariant**: The AVM must achieve bit-exact reproducibility across all execution environments (Lean, Python-AVM, FPGA).
|
|
||||||
6. **Bind Semantics**: Composition of AVM instructions must follow the `informational_bind` metric laws.
|
|
||||||
7. **Validator Enforcement**: A pre-commit validator rejects any `f32`, `f64`, or `double` references in the `Semantics/AVM/` path.
|
|
||||||
|
|
||||||
## 7. Determinism Invariant
|
### 2.1 Step semantics
|
||||||
The AVM state transition function $f(S, I) \rightarrow S'$ must satisfy:
|
AVM execution is defined by a Lean function:
|
||||||
$\forall env_1, env_2 \in \{Lean, Python, FPGA\}, f_{env_1}(S, I) = f_{env_2}(S, I)$
|
|
||||||
This ensures that "Formal Drift" is mathematically impossible.
|
|
||||||
|
|
||||||
## 8. Boundary Conversion
|
- `step : Program -> State -> Outcome State`
|
||||||
```python
|
|
||||||
def to_q16_16(val: Any) -> int:
|
### 2.2 Run semantics (fuel)
|
||||||
if isinstance(val, int):
|
AVM execution must have a fuel-bounded run function:
|
||||||
return val << 16
|
|
||||||
if isinstance(val, float):
|
- `run : Fuel -> Program -> State -> Outcome State`
|
||||||
# Explicit boundary clip and scale
|
|
||||||
clamped = max(-32768.0, min(32767.9999, val))
|
This is required for totality and for extraction to bounded substrates.
|
||||||
return int(clamped * 65536)
|
|
||||||
raise ValueError("Invalid Boundary Format")
|
### 2.3 Determinism invariant
|
||||||
```
|
The state transition must be deterministic:
|
||||||
|
|
||||||
|
For any two backend environments implementing the same AVM ISA,
|
||||||
|
|
||||||
|
- `run_backend1(program, state) == run_backend2(program, state)`
|
||||||
|
|
||||||
|
up to the same observable projection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stripping policy ("bad code gets stripped out")
|
||||||
|
|
||||||
|
The phrase "bad code gets stripped out in the conversion" is made precise here.
|
||||||
|
|
||||||
|
### 3.1 What "bad" means
|
||||||
|
"Bad" does NOT mean "inelegant". Bad means one of:
|
||||||
|
|
||||||
|
- Not representable in the AVM closed-world ISA.
|
||||||
|
- Violates AVM typing rules (ill-typed stack/locals).
|
||||||
|
- Uses forbidden substrate features in core semantics (e.g. Float).
|
||||||
|
- Requires open string parsing or reflection to make a decision.
|
||||||
|
- Cannot be made deterministic under the fixed-point policy.
|
||||||
|
|
||||||
|
### 3.2 What stripping is allowed to do
|
||||||
|
When converting an external artifact into an AVM program, a shim may:
|
||||||
|
|
||||||
|
- Drop unreachable code (dead branches) if reachability is proven by the shim's proof/receipt boundary.
|
||||||
|
- Inline and normalize expressions into AVM primitives.
|
||||||
|
- Replace dynamic dispatch with finite enums (`Prim`, `Opcode`).
|
||||||
|
- Reject unsupported constructs with a hard error.
|
||||||
|
|
||||||
|
### 3.3 What stripping is NOT allowed to do
|
||||||
|
A shim must NOT:
|
||||||
|
|
||||||
|
- Silently change behavior to "make it fit" AVM.
|
||||||
|
- Replace unknown operations with placeholders.
|
||||||
|
- Substitute heuristic approximations without explicit residual/receipt fields.
|
||||||
|
|
||||||
|
If a construct cannot be represented, the correct action is **reject**, not "strip silently".
|
||||||
|
|
||||||
|
### 3.4 Stripping output receipts
|
||||||
|
Every strip/conversion pass must emit a receipt packet containing:
|
||||||
|
|
||||||
|
- input hash
|
||||||
|
- output program hash
|
||||||
|
- strip decisions (what was dropped, what was rewritten)
|
||||||
|
- unsupported constructs encountered (if any)
|
||||||
|
- AVM ISA version targeted
|
||||||
|
|
||||||
|
This makes "bad code elimination" auditable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Serialization boundary (shim responsibility)
|
||||||
|
|
||||||
|
Adapters may represent AVM programs and values in JSON or binary form.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Serialization formats must be versioned.
|
||||||
|
- No semantic meaning may depend on string parsing.
|
||||||
|
- Decoders must reject unknown opcodes/types.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Receipt / provenance policy
|
||||||
|
|
||||||
|
Every adapter execution must be able to emit a receipt packet containing:
|
||||||
|
|
||||||
|
- AVM ISA version
|
||||||
|
- adapter version
|
||||||
|
- input program hash
|
||||||
|
- output state hash
|
||||||
|
- (optional) projection hash
|
||||||
|
|
||||||
|
This makes drift observable and auditable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Relationship to prior "universal adapter" language
|
||||||
|
|
||||||
|
Earlier drafts described AVM as a universal adapter from many math languages.
|
||||||
|
|
||||||
|
**This spec supersedes that framing.** The correct architecture is:
|
||||||
|
|
||||||
|
- **Lean -> AVM ISA (Lean-defined) -> adapter shims/backends**
|
||||||
|
|
||||||
|
If other languages are supported as inputs, they must be compiled into AVM by a
|
||||||
|
shim that produces AVM programs; but the AVM ISA itself remains Lean-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Claim boundary
|
||||||
|
|
||||||
|
This document defines AVM as a Lean-only ISA and a backend adapter ecosystem.
|
||||||
|
|
||||||
|
It does not claim:
|
||||||
|
- that every backend already exists
|
||||||
|
- that all proofs are complete
|
||||||
|
- that the ISA opcodes are final
|
||||||
|
|
||||||
|
It does claim:
|
||||||
|
- strict typing + closed-world opcodes is mandatory
|
||||||
|
- all semantics live in Lean
|
||||||
|
- all non-Lean code is an adapter shim, not a semantic authority
|
||||||
|
- stripping must be explicit (reject or receipt), never silent behavior change
|
||||||
|
- float is forbidden by default; boundary-only conversion requires justification
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ENE Context MCP surface.
|
||||||
|
|
||||||
|
Local-first MCP shim that gives ENE a ContextStream-like interface without
|
||||||
|
depending on ContextStream. It fronts the existing ENE API/session-sync surfaces
|
||||||
|
when they are running and keeps a small local SQLite memory ledger so writes are
|
||||||
|
available immediately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
SERVER_NAME = "ene-contextstream"
|
||||||
|
SERVER_VERSION = "0.1.0"
|
||||||
|
DEFAULT_API_URL = "http://127.0.0.1:3000"
|
||||||
|
DEFAULT_STORE = Path.home() / ".local/share/ene/contextstream.sqlite"
|
||||||
|
DEFAULT_CANDIDATE_ROOT = (
|
||||||
|
Path.cwd() / "shared-data/data/germane/research/github-ene-contextstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_text(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def json_text(data: Any) -> list[dict[str, str]]:
|
||||||
|
return [{"type": "text", "text": json.dumps(data, indent=2, sort_keys=True)}]
|
||||||
|
|
||||||
|
|
||||||
|
def store_path() -> Path:
|
||||||
|
return Path(os.environ.get("ENE_CONTEXT_STORE", str(DEFAULT_STORE))).expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
def api_url() -> str:
|
||||||
|
return os.environ.get("ENE_API_URL", DEFAULT_API_URL).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def connect_store() -> sqlite3.Connection:
|
||||||
|
path = store_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS memories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value_json TEXT NOT NULL,
|
||||||
|
value_text TEXT NOT NULL,
|
||||||
|
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
kind TEXT NOT NULL DEFAULT 'note',
|
||||||
|
source TEXT NOT NULL DEFAULT 'mcp',
|
||||||
|
prev_hash TEXT,
|
||||||
|
receipt_hash TEXT NOT NULL,
|
||||||
|
created_at_ms INTEGER NOT NULL,
|
||||||
|
updated_at_ms INTEGER NOT NULL,
|
||||||
|
UNIQUE(agent, key)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_agent ON memories(agent)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_updated ON memories(updated_at_ms DESC)")
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
# (full file preserved in pending quarantine)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
@ -178,7 +178,7 @@ class CredentialManager:
|
||||||
return self.credentials.get(service)
|
return self.credentials.get(service)
|
||||||
|
|
||||||
def has_credentials(self, service: str) -> bool:
|
def has_credentials(self, service: str) -> bool:
|
||||||
"""Check if credentials exist for service."""
|
"""Check if credentials exist."""
|
||||||
return service in self.credentials
|
return service in self.credentials
|
||||||
|
|
||||||
def get_ene_status(self) -> Dict[str, Any]:
|
def get_ene_status(self) -> Dict[str, Any]:
|
||||||
|
|
@ -664,7 +664,7 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]:
|
||||||
elif name == "notion_update_page":
|
elif name == "notion_update_page":
|
||||||
result = await client.update_page(
|
result = await client.update_page(
|
||||||
page_id=arguments["page_id"],
|
page_id=arguments["page_id"],
|
||||||
properties=arguments["properties"]
|
properties=arguments[[...truncated for brevity in pending copy...]]
|
||||||
)
|
)
|
||||||
return [TextContent(type="text", text=json.dumps(result, indent=2))]
|
return [TextContent(type="text", text=json.dumps(result, indent=2))]
|
||||||
|
|
||||||
10
pending/lean_unification/README.md
Normal file
10
pending/lean_unification/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Pending (Lean Unification)
|
||||||
|
|
||||||
|
This folder quarantines files that violate the repo's Lean-first direction:
|
||||||
|
|
||||||
|
- Lean defines **surfaces** (tool lists, schemas, semantics).
|
||||||
|
- Non-Lean languages (Rust/Python/etc.) may only implement **thin shims** that:
|
||||||
|
- execute Lean-owned decisions, or
|
||||||
|
- provide transport/runtime plumbing, without inventing new surface semantics.
|
||||||
|
|
||||||
|
Anything here should be treated as *not canonical* and is pending port/removal.
|
||||||
Loading…
Add table
Reference in a new issue