hypersteeldb 0.1.1

A database that compiles questions instead of guessing answers: typed vocabulary discovered from your documents, queries type-checked before they run, roaring-bitmap set algebra over reified hyperedges, and Dempster-Shafer evidence with an explicit conflict guard.
Documentation

HyperSteelDb

[dependencies]
hypersteeldb = "0.1"
use steeldb::SteelDb;

let db = SteelDb::ingest(documents)?;        // categories are derived from the text

for c in db.categories() {
    println!("you can ask about {}", c.wildcard());
}

match db.query("(and elevation/* (not state/negated))") {
    Ok(answer)   => println!("{} matching situations", answer.len()),
    Err(refused) => println!("{refused}"),   // tells you what the data *does* contain
}

No model files. No network. No configuration.

Run it yourself — this is cargo run --example quickstart, verbatim output:

indexed 8 situations

you can ask about:
  defeated/*     defeated, ecruteak, indigo, invitational
  elevation/*    elevation, habitat, near, sootopolis
  permitted/*    permitted, play, season

(and elevation/* (not state/negated))  ->  3 situations
  [3] A habitat survey recorded Aggron near Sootopolis City at an elevat
  [4] A habitat survey recorded Salamence near Sootopolis City at an ele

refused: survey/*
  dimension 'survey' is not in this corpus; facets are: defeated, elevation,
  entity, permitted, quantity, rel, state, time
  available: defeated/*, elevation/*, permitted/*

Two things to notice. The category names come from the words your documents actually use — elevation, not survey — so always print db.categories() before writing a query. And survey/* is refused rather than returning nothing, because a category that does not exist and a category with no matches are different answers.

How many documents do you need?

Categories are found from words that recur across documents, so a handful is not enough — with two documents nothing recurs and db.categories() is empty. Around eight gets you useful categories; more is better. entity/*, time/*, quantity/* and state/* work at any size, because those come from patterns rather than from statistics.


The problem this solves

You have a pile of documents and a question like "battles in Johto that weren't part of the Indigo tournament."

A vector search converts your question to numbers, finds the nearest documents, and returns them. It works well for "anything about battles?" and fails for that question in three specific ways:

what you ask what goes wrong
A but not B similarity has no "not" — documents mentioning both rank higher
how many in total? you get the top 10; you cannot know if there were 11
which trainers used a Mewtwo? if no document mentions Mewtwo, you still get answers

That third one is the expensive one. A confident wrong answer looks exactly like a right one, so you cannot tell which you received — and neither can an agent calling you in a loop.

What this does instead

It learns which categories your documents actually support, checks your question against them before running anything, then executes the survivors as bit arithmetic.

for c in db.categories() {
    println!("you can ask about {}", c.wildcard());   // battle/*, survey/*, series/* …
}

db.query("gene/brca1")?;   // Err: refused, and lists the categories that exist

A question the data cannot answer returns Err, not an empty list. That is the whole point: an empty result and an unanswerable question are different facts, and merging them is how a wrong answer gets produced.

Three verbs

verb needs costs when
ingest nothing deterministic, free every run
query nothing microseconds every request
learn a model one model call once, optional

ingest

let db = SteelDb::ingest(documents)?;        // from memory
let db = SteelDb::open("corpus/")?;          // from a directory of .md / .txt

Reads your documents, works out the categories, and indexes everything. Pure Rust — no ONNX, no Python, no downloads. Compiles to wasm32 and runs in a browser.

query

Queries are s-expressions, borrowed from Lisp: operation first, brackets for grouping. The whole language:

form meaning
battle/defeated situations carrying that exact tag
battle/* any value in the battle category
(and A B) both
(or A B) either
(not A) exclude
(num elevation_m gt 1000) numeric comparison
(evidence A :min-bel 0.8) only where the evidence for A is strong
(s-path :s 2 (source A) (target B)) things connected by at least 2 shared tags
(combine-ds :max-conflict 0.2 …) merge two sources, or refuse if they contradict

Results are complete sets, never ranked samples, so counting them means something:

let answer = db.query("(and survey/* (num elevation_m gt 1000))")?;
for (id, text) in db.resolve(&answer) {
    println!("{id}: {text}");         // every result traces back to its document
}

learn (optional)

ingest finds categories from word statistics. A language model can spot ones it missed. That needs a model, so learn is separate, explicit, and returns a suggestion rather than changing anything:

use steeldb::learn::Teacher;

let teacher  = Teacher::ollama("qwen2.5:0.5b")?;      // local, free, nothing leaves the machine
let proposal = teacher.propose_categories(&db).await?;

println!("{proposal}");                                // review it first
let verdicts = db.adopt(&proposal);                    // you decide

Adopting runs each suggestion through the same test ingest uses: enough coverage, and not a near-duplicate of something already there. A model cannot add a category that the deterministic test would reject — so using a small local model is safe. A bad suggestion is rejected, not absorbed.

You can also point it at Bedrock (Teacher::bedrock), which does cost money per call.

Artefacts: run learn once, not every time

learn is slow, needs a model, and may answer slightly differently each time. You do not want that on every startup. So save what it produced:

db.save(".hypersteeldb")?;                              // after learning
.hypersteeldb/
  .gitignore         excludes training/ — written before anything else
  manifest.json      what produced this, when, and which parts hold document text
  vocabulary.json    the categories and their words
  gazetteer.json     multi-word names kept whole
  relations.json     relation verbs
  training/          only if you asked for it — see below
    spans.jsonl      labelled passages for a tagger finetune

Then every run after that follows those files:

let db = SteelDb::ingest_using(documents, ".hypersteeldb")?;

Same vocabulary, same answers, no model, no network. Commit the directory next to your code — it is small, diffable, and a vocabulary change shows up in review like any other change.

The vocabulary files contain no document text

vocabulary.json, gazetteer.json and relations.json are derived vocabulary: category names, signal words, name surfaces, verbs. Not your documents, and not an index of them. Tests assert it — both against whole documents and against six-word fragments, since a partial copy leaks just as surely as a whole one.

You still need the documents to ingest. The artefacts describe how to read them, not what they said.

The finetune set is the one exception, so it is kept apart

let n = db.save_with_training(".hypersteeldb")?;   // n labelled passages

A finetune set is labelled spans, and a span label is meaningless without the words it points at. So this one file does contain your document text. Three things keep that from becoming an accident:

  • it goes to training/, not next to the vocabulary;
  • save writes the .gitignore excluding it before writing the data, so the rule cannot be missing;
  • manifest.json lists it under contains_document_text, so a CI check can refuse to publish a directory without needing to know the layout.

After cloning a repository you will have the vocabulary and not the training set. That is the intended state: ingest and query never need it.

What you get for free, and what needs a download

The default build has no model dependencies at all. Everything above works with cargo add hypersteeldb and nothing else.

Optional features add trained models, which are fetched on request, never automatically:

feature adds model needed
— (default) ingest, query, artefacts, evidence, topology none
paddock learn against any local OpenAI-compatible server yours
bedrock learn against Amazon Bedrock hosted
onnx trained span tagger (better categories) 168 MB
embed optimal-transport discovery 15 MB
native in-process inference varies

Trained heads (1.8 MB) ship inside the crate. Larger weights are resolved by steeldb::models::resolve, which searches STEELDB_MODELS, then ~/.steeldb/models, then ./models, and returns an error naming the repository, revision, size and licence if it finds nothing. It never downloads on its own.

Beyond the basics

  • Evidence. db.belief("state/negated") returns [belief, plausibility] rather than one number, so "nobody said" is distinguishable from "sources disagree". (combine-ds …) refuses to merge contradictory sources instead of averaging them into a consensus nobody holds.
  • Structure. db.s_path(a, b, 2) finds connections that share at least two tags, which suppresses the drift you get from following single links. db.filtration(6) sweeps that threshold and shows real structure separating from coincidence.
  • Bounded by construction. Every tag is attached to a situation, so evaluation is flat set intersection rather than an open-ended graph walk. Cost is rows ÷ register width, and no query can escape it.

Try it without installing anything

An interactive version of the paper runs the real engine in your browser, compiled to WebAssembly: https://huggingface.co/spaces/cp500/steeldb-ontology-sensing

The name

Registeel is the steel golem of the Pokémon Regi trio — a sealed thing that only opens for the right sequence. Fitting for an engine whose whole argument is that it refuses malformed questions.

Licence

MIT. Third-party models keep their own licences, recorded in steeldb::models::ARTIFACTS with attribution and a pinned revision.