mirror of
https://github.com/hanzoai/semantic.git
synced 2026-10-30 15:04:19 +00:00
The first commit was the shape. This is the implementation: every stage of the pipeline, the graph and its measures, the type system over it, entailment, deduplication and conflict resolution, the decision layer, the stores and the export formats. Twenty-six thousand lines of Go and sixteen thousand of tests, against no dependency but the standard library. Where the Python reaches for torch, spacy or a database driver, the Go states an interface and reports what is missing rather than pretending. A caller can tell "this build cannot serve that" from "nobody has heard of it", which is the difference between a port and a stub. The deterministic paths are complete and are what the tests exercise; the model-shaped ones are one adapter away. Porting a bug is not parity, so several places now disagree with the original on purpose. The JSON repair that closed a truncated reply counted each bracket kind separately and closed them backwards on the one input it exists for; it now works off a stack and ignores brackets inside strings. The entity patterns joined words with \s, so a heading ran into the sentence beneath it and became one name while the real relation was lost; a name is separated by horizontal space now. Eigenvector centrality ran plain power iteration, which on a bipartite graph swings between the two ends of a sign-paired eigenvalue forever and reported every node in a star as equally central; it iterates with A+I, which moves no eigenvector and settles. Similarity summed its weighted parts in map order, and float addition is not associative, so the same comparison scored differently between runs and duplicate detection returned a different answer each time. An inferred ontology named object-property endpoints by raw source type and data-property endpoints by class, so the schema referred to classes it did not contain. A property with no IRI of its own was minted as a class and came back Pascal-cased, renaming the term on the way out of the document. The command built a pipeline with every stage nil and panicked on each invocation. It is now the composition the package documents, and Run reports a missing ingester instead of dereferencing it. Co-authored-by: Hanzo Dev <[email protected]>
382 lines
11 KiB
Go
382 lines
11 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
)
|
|
|
|
// File keeps a store in one file on disk. It answers every question Mem
|
|
// does, from memory, and owes its durability to a log: each write is
|
|
// appended as one JSON record and flushed before the call returns, so
|
|
// reopening the file replays exactly what was acknowledged.
|
|
//
|
|
// A process that dies mid-write leaves a partial last line. A record counts
|
|
// only if it is terminated and decodes, so the partial line is discarded on
|
|
// the next open and the file is truncated back to the last whole record —
|
|
// the write that never returned never happened.
|
|
//
|
|
// The log grows with every write. Compact folds it into a single snapshot
|
|
// record, written beside the file and renamed over it, so a crash during
|
|
// compaction leaves the old log intact.
|
|
type File struct {
|
|
mu sync.Mutex
|
|
mem Mem
|
|
f *os.File
|
|
path string
|
|
// end is the offset of the last whole record, which is where the next
|
|
// one is appended.
|
|
end int64
|
|
}
|
|
|
|
// File answers all three questions, like the store it keeps.
|
|
var (
|
|
_ Vector = (*File)(nil)
|
|
_ Graph = (*File)(nil)
|
|
_ Triple = (*File)(nil)
|
|
)
|
|
|
|
// Open opens the store at path, creating it if it is not there, and replays
|
|
// what was written.
|
|
func Open(path string) (*File, error) {
|
|
if path == "" {
|
|
return nil, fmt.Errorf("store: open: %w", os.ErrInvalid)
|
|
}
|
|
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: open %s: %w", path, err)
|
|
}
|
|
s := &File{f: f, path: path}
|
|
if err := s.replay(); err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Close flushes and closes the file.
|
|
func (s *File) Close() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.f == nil {
|
|
return nil
|
|
}
|
|
err := s.f.Close()
|
|
s.f = nil
|
|
if err != nil {
|
|
return fmt.Errorf("store: close %s: %w", s.path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Name is the path the store is kept at, as os.File reports its own.
|
|
func (s *File) Name() string { return s.path }
|
|
|
|
// log is one entry in the write-ahead log. One shape carries every kind of
|
|
// write, because a second shape would mean a second way to read the file.
|
|
type log struct {
|
|
Op string `json:"op"`
|
|
ID string `json:"id,omitempty"`
|
|
IDs []string `json:"ids,omitempty"`
|
|
Vec []float32 `json:"vec,omitempty"`
|
|
Meta map[string]any `json:"meta,omitempty"`
|
|
From string `json:"from,omitempty"`
|
|
To string `json:"to,omitempty"`
|
|
Label string `json:"label,omitempty"`
|
|
S string `json:"s,omitempty"`
|
|
P string `json:"p,omitempty"`
|
|
O string `json:"o,omitempty"`
|
|
Snap *Snap `json:"snap,omitempty"`
|
|
}
|
|
|
|
// replay rebuilds the store from the log and truncates a partial tail.
|
|
func (s *File) replay() error {
|
|
b, err := os.ReadFile(s.path)
|
|
if err != nil {
|
|
return fmt.Errorf("store: read %s: %w", s.path, err)
|
|
}
|
|
var end int64
|
|
for rest := b; len(rest) > 0; {
|
|
i := bytes.IndexByte(rest, '\n')
|
|
if i < 0 {
|
|
break // a write that did not finish
|
|
}
|
|
line, next := rest[:i], rest[i+1:]
|
|
var e log
|
|
if err := json.Unmarshal(line, &e); err != nil {
|
|
break // a record that did not survive
|
|
}
|
|
if err := s.apply(e); err != nil {
|
|
return fmt.Errorf("store: replay %s: %w", s.path, err)
|
|
}
|
|
end += int64(i) + 1
|
|
rest = next
|
|
}
|
|
if end != int64(len(b)) {
|
|
if err := s.f.Truncate(end); err != nil {
|
|
return fmt.Errorf("store: truncate %s: %w", s.path, err)
|
|
}
|
|
}
|
|
s.end = end
|
|
return nil
|
|
}
|
|
|
|
// apply replays one record into memory. Replay is not a write, so it takes
|
|
// no context of its own.
|
|
func (s *File) apply(e log) error {
|
|
ctx := context.Background()
|
|
switch e.Op {
|
|
case "snap":
|
|
if e.Snap == nil {
|
|
return nil
|
|
}
|
|
return s.mem.Load(ctx, *e.Snap)
|
|
case "rank":
|
|
s.mem.Rank(Metric(e.Label))
|
|
return nil
|
|
case "put":
|
|
return s.mem.Put(ctx, e.ID, e.Vec, e.Meta)
|
|
case "drop":
|
|
return s.mem.Drop(ctx, e.IDs...)
|
|
case "node":
|
|
return s.mem.Node(ctx, e.ID, e.Meta)
|
|
case "edge":
|
|
return s.mem.Edge(ctx, e.From, e.To, e.Label)
|
|
case "cut":
|
|
return s.mem.Cut(ctx, e.ID)
|
|
case "unlink":
|
|
return s.mem.Unlink(ctx, e.From, e.To, e.Label)
|
|
case "assert":
|
|
return s.mem.Assert(ctx, e.S, e.P, e.O)
|
|
case "retract":
|
|
return s.mem.Retract(ctx, e.S, e.P, e.O)
|
|
}
|
|
return fmt.Errorf("unknown record %q", e.Op)
|
|
}
|
|
|
|
// write appends one record and flushes it, then applies it. The order is
|
|
// what makes the file the record of truth: a write that is not on disk is
|
|
// not in memory either.
|
|
func (s *File) write(e log) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.f == nil {
|
|
return fmt.Errorf("store: write %s: %w", s.path, os.ErrClosed)
|
|
}
|
|
b, err := json.Marshal(e)
|
|
if err != nil {
|
|
return fmt.Errorf("store: encode %s: %w", e.Op, err)
|
|
}
|
|
b = append(b, '\n')
|
|
n, err := s.f.WriteAt(b, s.end)
|
|
if err != nil {
|
|
s.f.Truncate(s.end)
|
|
return fmt.Errorf("store: append %s: %w", s.path, err)
|
|
}
|
|
if err := s.f.Sync(); err != nil {
|
|
s.f.Truncate(s.end)
|
|
return fmt.Errorf("store: sync %s: %w", s.path, err)
|
|
}
|
|
s.end += int64(n)
|
|
return s.apply(e)
|
|
}
|
|
|
|
// Compact rewrites the file as one snapshot, discarding a log that has
|
|
// grown longer than the state it describes. The snapshot is written beside
|
|
// the file and renamed over it, so an interrupted compaction leaves the
|
|
// store as it was.
|
|
func (s *File) Compact() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.f == nil {
|
|
return fmt.Errorf("store: compact %s: %w", s.path, os.ErrClosed)
|
|
}
|
|
snap := s.mem.Dump()
|
|
b, err := json.Marshal(log{Op: "snap", Snap: &snap})
|
|
if err != nil {
|
|
return fmt.Errorf("store: encode snapshot: %w", err)
|
|
}
|
|
b = append(b, '\n')
|
|
|
|
tmp := s.path + ".new"
|
|
f, err := os.OpenFile(tmp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
|
|
if err != nil {
|
|
return fmt.Errorf("store: compact %s: %w", s.path, err)
|
|
}
|
|
if _, err := f.Write(b); err != nil {
|
|
f.Close()
|
|
os.Remove(tmp)
|
|
return fmt.Errorf("store: compact %s: %w", s.path, err)
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
f.Close()
|
|
os.Remove(tmp)
|
|
return fmt.Errorf("store: compact %s: %w", s.path, err)
|
|
}
|
|
if err := os.Rename(tmp, s.path); err != nil {
|
|
f.Close()
|
|
os.Remove(tmp)
|
|
return fmt.Errorf("store: compact %s: %w", s.path, err)
|
|
}
|
|
if dir, err := os.Open(filepath.Dir(s.path)); err == nil {
|
|
dir.Sync()
|
|
dir.Close()
|
|
}
|
|
s.f.Close()
|
|
s.f = f
|
|
s.end = int64(len(b))
|
|
return nil
|
|
}
|
|
|
|
// Rank sets how nearness is measured and records the choice, so a store
|
|
// reopened later ranks the way it was told to.
|
|
func (s *File) Rank(v Metric) error { return s.write(log{Op: "rank", Label: string(v)}) }
|
|
|
|
// Metric reports how nearness is measured. See Mem.Metric.
|
|
func (s *File) Metric() Metric { return s.mem.Metric() }
|
|
|
|
// Put writes a vector under an id. See Mem.Put.
|
|
func (s *File) Put(ctx context.Context, id string, v []float32, meta map[string]any) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if id == "" {
|
|
return ErrID
|
|
}
|
|
return s.write(log{Op: "put", ID: id, Vec: v, Meta: meta})
|
|
}
|
|
|
|
// Drop removes vectors. See Mem.Drop.
|
|
func (s *File) Drop(ctx context.Context, ids ...string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
return s.write(log{Op: "drop", IDs: ids})
|
|
}
|
|
|
|
// Node writes a node and its properties. See Mem.Node.
|
|
func (s *File) Node(ctx context.Context, id string, props map[string]any) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if id == "" {
|
|
return ErrID
|
|
}
|
|
return s.write(log{Op: "node", ID: id, Meta: props})
|
|
}
|
|
|
|
// Edge links two nodes under a label. See Mem.Edge.
|
|
func (s *File) Edge(ctx context.Context, from, to, label string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if from == "" || to == "" {
|
|
return ErrID
|
|
}
|
|
return s.write(log{Op: "edge", From: from, To: to, Label: label})
|
|
}
|
|
|
|
// Cut removes a node and every edge that touched it. See Mem.Cut.
|
|
func (s *File) Cut(ctx context.Context, id string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if id == "" {
|
|
return ErrID
|
|
}
|
|
return s.write(log{Op: "cut", ID: id})
|
|
}
|
|
|
|
// Unlink removes the edges that match a pattern. See Mem.Unlink.
|
|
func (s *File) Unlink(ctx context.Context, from, to, label string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
return s.write(log{Op: "unlink", From: from, To: to, Label: label})
|
|
}
|
|
|
|
// Assert records one triple. See Mem.Assert.
|
|
func (s *File) Assert(ctx context.Context, sub, pred, obj string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if sub == "" || pred == "" || obj == "" {
|
|
return ErrTerm
|
|
}
|
|
return s.write(log{Op: "assert", S: sub, P: pred, O: obj})
|
|
}
|
|
|
|
// Retract removes the triples that match a pattern. See Mem.Retract.
|
|
func (s *File) Retract(ctx context.Context, sub, pred, obj string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
return s.write(log{Op: "retract", S: sub, P: pred, O: obj})
|
|
}
|
|
|
|
// Reads go straight to memory: the file is how the store survives, not how
|
|
// it is queried.
|
|
|
|
// Near returns the k nearest vectors. See Mem.Near.
|
|
func (s *File) Near(ctx context.Context, v []float32, k int) ([]Match, error) {
|
|
return s.mem.Near(ctx, v, k)
|
|
}
|
|
|
|
// Search returns the matches a query admits. See Mem.Search.
|
|
func (s *File) Search(ctx context.Context, q Query) ([]Match, error) { return s.mem.Search(ctx, q) }
|
|
|
|
// Get returns a vector and its metadata. See Mem.Get.
|
|
func (s *File) Get(id string) ([]float32, map[string]any, bool) { return s.mem.Get(id) }
|
|
|
|
// Scan pages through the stored vectors. See Mem.Scan.
|
|
func (s *File) Scan(offset, limit int) []Match { return s.mem.Scan(offset, limit) }
|
|
|
|
// Props returns a node's properties. See Mem.Props.
|
|
func (s *File) Props(id string) (map[string]any, bool) { return s.mem.Props(id) }
|
|
|
|
// Nodes lists every node. See Mem.Nodes.
|
|
func (s *File) Nodes() []string { return s.mem.Nodes() }
|
|
|
|
// Out lists the nodes this one points at. See Mem.Out.
|
|
func (s *File) Out(ctx context.Context, id string) ([]string, error) { return s.mem.Out(ctx, id) }
|
|
|
|
// In lists the nodes that point at this one. See Mem.In.
|
|
func (s *File) In(ctx context.Context, id string) ([]string, error) { return s.mem.In(ctx, id) }
|
|
|
|
// Edges lists the links that match a pattern. See Mem.Edges.
|
|
func (s *File) Edges(from, to, label string) []Link { return s.mem.Edges(from, to, label) }
|
|
|
|
// Path returns a shortest path between two nodes. See Mem.Path.
|
|
func (s *File) Path(ctx context.Context, from, to string) ([]string, error) {
|
|
return s.mem.Path(ctx, from, to)
|
|
}
|
|
|
|
// Reach lists the nodes within hops steps of one. See Mem.Reach.
|
|
func (s *File) Reach(ctx context.Context, id string, hops int) ([]string, error) {
|
|
return s.mem.Reach(ctx, id, hops)
|
|
}
|
|
|
|
// Degree counts the edges into and out of a node. See Mem.Degree.
|
|
func (s *File) Degree(id string) (in, out int) { return s.mem.Degree(id) }
|
|
|
|
// Components groups the nodes into connected components. See Mem.Components.
|
|
func (s *File) Components() [][]string { return s.mem.Components() }
|
|
|
|
// Match returns the triples that fit a pattern. See Mem.Match.
|
|
func (s *File) Match(ctx context.Context, sub, pred, obj string) ([][3]string, error) {
|
|
return s.mem.Match(ctx, sub, pred, obj)
|
|
}
|
|
|
|
// Stat counts what the store holds. See Mem.Stat.
|
|
func (s *File) Stat() Stat { return s.mem.Stat() }
|
|
|
|
// Dump returns everything the store holds. See Mem.Dump.
|
|
func (s *File) Dump() Snap { return s.mem.Dump() }
|