Metadata-Version: 2.5
Name: xgen-omnifuse
Version: 0.8.0
Summary: Backend-agnostic one-shot GraphRAG: fuse vector + graph (label/class/relation) seeds with MMR diversity into a single synthesis. Zero-infra default.
Project-URL: Homepage, https://github.com/PlateerLab/xgen-omnifuse
Project-URL: Issues, https://github.com/PlateerLab/xgen-omnifuse/issues
Author-email: jinsoo96 <wlstn010203@gmail.com>
License: OmniFuse Source-Available License
        =================================
        
        Copyright (c) 2026 Jinsoo Kim (GitHub: jinsoo96, wlstn010203@gmail.com).
        All rights reserved.
        
        This is NOT an open-source license. The source code of OmniFuse (the
        "Software") is made publicly visible so that it can be read, evaluated and
        cited. Publication of the source, and its availability on GitHub or on the
        Python Package Index, does NOT grant anyone permission to use it.
        
        
        1. Ownership
        ------------
        The Software, including all source code, documentation, benchmark artifacts,
        evaluation data authored for this project, and the names "OmniFuse",
        "js-omnifuse" and "xgen-omnifuse", is the sole property of the copyright
        holder named above (the "Owner"). The repository PlateerLab/xgen-omnifuse is
        the origin of this Software; jinsoo96/js-omnifuse is a mirror of it, and every
        other mirror or redistribution is derived from that origin and remains subject
        to this license.
        
        
        2. What you MAY do without asking
        ---------------------------------
        Subject to keeping this license file and all copyright notices intact, you
        may:
        
          a. read, browse and study the source code;
          b. clone or download it to your own machine for personal, non-commercial
             evaluation and for the purpose of reviewing or citing the work;
          c. quote short excerpts of the source or documentation for commentary,
             review, teaching or academic citation, with attribution to the Owner
             and a link to https://github.com/jinsoo96/js-omnifuse.
        
        
        3. What you may NOT do without prior written permission
        --------------------------------------------------------
        Except as expressly allowed in Section 2, you may NOT, in whole or in part:
        
          a. use the Software in any product, service, internal tool, pipeline or
             workflow, whether commercial, non-commercial, academic or governmental;
          b. copy, reproduce, modify, translate, adapt or create derivative works;
          c. merge it into, or use it as a component of, any other software;
          d. publish, distribute, sublicense, rent, lease, sell or otherwise make it
             available to third parties, including by re-uploading it to any package
             index, model hub or code host;
          e. use the Software, its documentation or its benchmark results to train,
             fine-tune, evaluate or prompt any machine-learning model or coding
             assistant that produces derivative code;
          f. remove, obscure or alter this license, the copyright notice, or any
             attribution to the Owner;
          g. use the names "OmniFuse", "js-omnifuse" or "xgen-omnifuse", or the
             Owner's name, to endorse or promote products derived from or related
             to the Software.
        
        "Written permission" means an explicit, dated grant from the Owner
        identifying the licensee and the permitted use. Permission may be limited in
        scope and duration and may be revoked by the Owner. The absence of a reply
        is not permission.
        
        
        4. Grant to the PlateerLab organization
        ---------------------------------------
        This section is the Owner's written permission under Section 3, given to the
        GitHub organization "PlateerLab" (https://github.com/PlateerLab) and to its
        members for as long as they are members.
        
        Within that scope they may use, copy, modify, merge, build, package, deploy
        and distribute the Software, and create derivative works of it, as part of
        the products and services of Plateer Co., Ltd. This includes committing to
        the origin repository, publishing the "xgen-omnifuse" package, and shipping
        the Software inside Plateer deployments, on-premises or otherwise.
        
        This grant does not make the Software open source and does not extend to
        anyone outside the organization. Redistribution to third parties is allowed
        only as a component of a Plateer product or service, under this same
        license. Section 6 (Contributions) continues to apply to changes made under
        this grant.
        
        
        5. Mirrors and package distributions
        ------------------------------------
        The mirror at https://github.com/jinsoo96/js-omnifuse follows the origin
        repository automatically and carries this same license. The Python package
        "xgen-omnifuse" is published from the origin repository. Installing the
        package with a package manager is a technical act and does not by itself
        constitute permission under Section 3; permission comes from Section 4 for
        PlateerLab members and from a written grant by the Owner for anyone else.
        
        
        6. Contributions
        ----------------
        By submitting any contribution (code, documentation, benchmark data, issue
        text or otherwise) to this Software, you agree that the contribution becomes
        the property of the Owner, and you assign to the Owner all copyright and
        related rights in it to the fullest extent permitted by law. Where such
        assignment is not legally possible, you grant the Owner an exclusive,
        perpetual, irrevocable, worldwide, royalty-free license to use the
        contribution for any purpose, including relicensing. You represent that you
        have the right to make this assignment.
        
        
        7. Termination
        --------------
        Any breach of Section 3 terminates whatever permission you had under
        Section 2 immediately and automatically. Upon termination you must destroy
        all copies of the Software in your possession.
        
        
        8. No warranty
        --------------
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
        OWNER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
        ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
        WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
        
        
        9. Governing law
        ----------------
        This license is governed by the laws of the Republic of Korea. Any dispute
        arising from it shall be subject to the exclusive jurisdiction of the courts
        of Seoul, Republic of Korea.
        
        
        10. Requesting permission
        ------------------------
        To request permission for any use not allowed by Section 2, contact the
        Owner at wlstn010203@gmail.com or open an issue at
        https://github.com/jinsoo96/js-omnifuse/issues describing the intended use.
License-File: LICENSE
Keywords: bm25,graphrag,knowledge-graph,mmr,ontology,rag,retrieval
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Requires-Dist: tomli>=2; (python_version < '3.11') and extra == 'dev'
Provides-Extra: fuseki
Requires-Dist: httpx>=0.27; extra == 'fuseki'
Provides-Extra: qdrant
Requires-Dist: qdrant-client>=1.12; extra == 'qdrant'
Description-Content-Type: text/markdown

# OmniFuse

**Portable knowledge inputs:** [hierarchy, embeddings and graph interfaces](docs/knowledge.md).

**Backend-agnostic, one-shot GraphRAG.** Fire several retrieval strategies at once —
vector/lexical passages **+** graph label-linking **+** class enumeration **+** relation
expansion — and *fuse* them with MMR diversity into a single LLM synthesis. No
iterative ReAct tool loop. **Zero infra, zero lock-in:** the full algorithm runs on a
pure-Python in-memory backend (dict + BM25), and swaps to Fuseki / Qdrant / any LLM by
passing objects that match three small protocols.

```python
from omnifuse import from_triples

of = from_triples(                                  # nodes are inferred; no DB, no API key
    [("담보", "instanceOf", "규정"), ("담보", "한도", "5억")],
    chunks=[("c1", "담보 한도는 5억원이다", ["담보"])],
)
print(of.search("담보 한도").answer)
```

Load however you have the data — all zero-dep, same `search()`:

```python
from omnifuse import from_jsonl, from_csv, from_fuseki, build_inmemory
of = from_jsonl(triples="t.jsonl", chunks="c.jsonl")
of = from_csv(triples="triples.csv", chunks="chunks.csv")
of = from_fuseki("http://localhost:3030/ds/query", graph_uri="urn:g", user="admin", password="…")
of = build_inmemory(nodes, triples, chunks)         # explicit Node/Triple/Chunk
```

Build the index once, start warm afterwards (stdlib pickle, zero deps):

```python
from omnifuse import save_index, load_index
save_index(of, "idx.pkl")
of = load_index("idx.pkl")            # ~29x faster than rebuilding; pass embedder=/llm= here
```

## Repository model

[`PlateerLab/xgen-omnifuse`](https://github.com/PlateerLab/xgen-omnifuse) is the source of
truth and publishes the Python package `xgen-omnifuse`. Members of the PlateerLab
organization commit there directly and cut releases there.

[`jinsoo96/js-omnifuse`](https://github.com/jinsoo96/js-omnifuse) is a read-only mirror. It
runs [`sync-from-xgen-omnifuse.yml`](.github/workflows/sync-from-xgen-omnifuse.yml) every 15
minutes and on manual dispatch, fast-forwarding from the organization repository with the
repository's own `GITHUB_TOKEN`; no personal credential is involved, so there is nothing to
expire.

## Why graph fusion (not just vectors)

Pure vector RAG answers from the top-k passages it happens to embed near the query. A
graph store also gives you operations cosine similarity can't:

- **Complete enumeration** — *all* instances of a class ("list every regulation"), exact counts.
- **Relations / multi-hop** — what an entity is connected to, 1-hop neighbors, paths.
- **Minority evidence survives** — MMR diversity keeps the decisive exception/warning that
  near-duplicate passages would otherwise crowd out of a fixed top-k.

OmniFuse fuses both: the vector seed for *content*, the graph seeds for *structure*.

## Design — algorithm as a library

The algorithm only talks to three `typing.Protocol`s, never to a database:

```python
class GraphStore(Protocol):
    def search_labels(self, query, *, limit=30) -> list[tuple[Node, float]]: ...   # full-text label search
    def class_instances(self, class_id, *, limit=1000) -> list[Node]: ...          # enumeration
    def neighbors(self, node_id, *, hops=1, limit=100) -> list[tuple[str,str,str]]: ...  # traversal
    def count_class(self, class_id) -> int: ...
    def get_node(self, node_id) -> Node | None: ...

class VectorStore(Protocol):
    def search(self, query, *, limit=20) -> list[tuple[Chunk, float]]: ...
    def fetch(self, ids) -> list[Chunk]: ...

class LLM(Protocol):
    def generate(self, prompt, *, system="", timeout=None) -> str: ...
```

- **Zero-infra default** — `InMemoryGraph` indexes node labels with **BM25** (CJK
  character n-grams, so Korean/CJK search works with no morphological analyzer).
  `InMemoryVector` picks its mode from what the chunks carry: **hybrid** (dense
  cosine and lexical BM25 min-max normalized per query, then weighted) when embeddings *and*
  text are present, **dense** cosine with embeddings only, else **field-weighted
  BM25** (`BM25F`) that scores a chunk's short `title` above its body.
- **`dependencies = []`** — the core needs nothing but the standard library. Real backends
  are optional extras (`pip install "xgen-omnifuse[fuseki,qdrant]"`).
- **Bring your own LLM** — pass anything with `generate(...)`; the bundled `EchoLLM`
  returns the fused evidence so the pipeline runs end-to-end with no API key.

### Extension points (no subclassing)

Everything an integration needs to change is a constructor argument or a public function;
nothing requires reaching into private names.

| Hook | What it does |
|---|---|
| `OmniFuse(prompt_builder=fn)` | `fn(question, evidence, relations, class_seed) -> str` replaces the synthesis prompt (any language or framing). `OmniFuse.build_prompt` is the public default. |
| `search(q, synthesize=False)` | Stop before the LLM. `SearchResult.evidence` / `.prompt` / `.system` let you stream your own generation or hand the evidence to an agent. |
| `OmniFuse(specificity_weight=w)` | Re-weight chunks by the most specific entity they mention (`graph.node_specificity`). |
| `omnifuse.graph_rank` | `graph_candidate_chunks` (label-linked seeds → 1-hop → chunks, hub-discounted) and `ppr_seeds` / `ppr_chunk_scores` / `blend_ppr` (personalized PageRank over a candidate pool). Optional store methods are the `GraphRankStore` protocol. |
| `PreloadedVectorStore` | Wrap candidates an external index already scored; only the lexical side is built here and fused with the given dense scores. |
| `omnifuse.fusion.natural_cut` | Self-sizing top-k: cuts a descending score list where it splits into a high and a low group (two-class Otsu), so the count comes from the distribution itself. `min_k` / `max_k` only bound it. Complements `dynamic_cut`. |

## The pipeline (`OmniFuse.search`)

1. vector/lexical seed + **1-hop graph fusion** → adaptive top-k (score-distribution cut, not fixed k)
2. graph label-linking → 1-hop relations
3. class enumeration (complete list/count)
4. HippoRAG — entities of the retrieved chunks → 1-hop expansion
5. evidence assembled with **MMR** diversity (Jaccard, no embeddings needed)
6. one LLM synthesis over the fused evidence
7. honest `evidence_nodes` — only the nodes the answer actually cites

### `OmniFuse.retrieve` — ranking, not just synthesis

`retrieve(question)` returns the ranked `(chunk, score)` list with no LLM call —
use it directly for search/eval. On top of the vector seed it does **graph-companion
fusion**: a passage that a strong seed *references/links to* is surfaced beside it
(companion score = `fusion_alpha` × seed), so multi-hop evidence that shares no query
vocabulary lands in one shot — no agent, no LLM. `search()` builds its chunks and
evidence on `retrieve()`. Opt out with `graph_fusion=False`.

For a static text-only corpus, `build_inmemory(..., auto_link_titles=True)` derives
directed `references` edges when a passage names another passage's unambiguous title:

```python
of = build_inmemory([], [], chunks, auto_link_titles=True)
```

The linker uses a token trie, ignores ambiguous and single-token aliases, and recognizes
conservative name forms such as `Philip V` for `Philip V of Spain`. It is opt-in because
an incrementally mutable corpus needs an equally mutable graph; combining
`auto_link_titles=True` with `mutable=True` is rejected instead of leaving stale edges.

## Install

```bash
pip install xgen-omnifuse            # core (zero deps)
pip install "xgen-omnifuse[dev]"     # + pytest, ruff
```

Run the demo with no install:

```bash
python examples/quickstart.py
```

## Layout

```
src/omnifuse/
  protocols.py     # GraphStore / VectorStore / LLM  (the swap points)
  models.py        # Node, Triple, Chunk (+ optional title), SearchResult
  text.py          # tokenizer + BM25 + BM25F (field-weighted, CJK n-grams)
  lexical_rerank.py # bounded phrase/surface reranking + Korean zero-hit fallback
  linking.py       # deterministic title-mention graph edges for static text corpora
  fusion.py        # MMR, adaptive top-k, relation ranking
  oneshot.py       # OmniFuse.search / retrieve — the fusion algorithm
  backends/memory.py  # InMemoryGraph + InMemoryVector (hybrid/dense/lexical, zero infra)
  llm.py           # EchoLLM, CallableLLM
  feedback.py      # Feedback — memory as a BM25F evidence field
  facade.py        # build_inmemory(...), save_index / load_index
examples/  tests/  eval/   # eval/ = head-to-head benchmark vs synaptic-memory
```

## Two interchangeable modes (same algorithm)

```python
# (a) self-contained — zero infra
from omnifuse import build_inmemory
of = build_inmemory(nodes, triples, chunks)

# (b) backed by Apache Jena Fuseki (or any SPARQL endpoint) — graph-only or with a vector store
from omnifuse import OmniFuse, InMemoryVector
from omnifuse.backends.fuseki import FusekiGraph
graph = FusekiGraph("http://localhost:3030/ds/query", graph_uri="urn:my-graph", user="admin", password="…")
of = OmniFuse(graph, InMemoryVector([]))   # search() unchanged
```

`FusekiGraph` is stdlib-only (urllib) and uses portable `FILTER(CONTAINS(...))`, so it
works on **any** SPARQL 1.1 store — not just jena-text.

## How the current ranking works

The current retrieval path is one general algorithm. It contains no benchmark name, query id,
qrel, expected answer or document-specific exception.

1. **Lossless index, query-only cleanup.** Documents keep every token. At query time,
   Korean request endings and English closed-class grammar words are removed so subject
   terms carry the rank. If cleanup would remove everything, OmniFuse falls back to the
   original query tokens.
2. **BM25F candidate admission.** Title and body fields produce a configured bounded
   frontier (`max(limit, pool)`, default pool 40); title matches remain more informative
   than the same token buried in a long passage.
3. **Complete-word coordination.** When Korean word-boundary evidence exists, candidates
   containing the complete subject word are preferred over substring-only matches. At most
   one excluded candidate is restored, and only when its original BM25F score exceeds the
   weakest retained complete-word hit. The final top-K naturally decides whether it stays.
4. **Korean zero-hit recovery.** If ordinary lexical search returns no candidate, a
   character-evidence BM25 fallback scans that query's corpus once. It shares the configured
   BM25 constants, preserves mutable slot IDs, adds no persistent character index, and never
   runs on a normal hit path.
5. **Phrase evidence.** Ordered query bigrams vote inside the bounded frontier. Only matched
   query pairs are retained for each candidate.
6. **Personal-memory surface fusion.** First-person memory questions also receive an
   independent raw phrase/coverage ranking. Normalized lexical rank and surface rank are
   combined as `0.4 / lexical_rank + 0.6 / surface_rank`, an inverse-rank fusion inspired by
   [Reciprocal Rank Fusion](https://research.google/pubs/reciprocal-rank-fusion-outperforms-condorcet-and-individual-rank-learning-methods/).
   It only reorders the already admitted candidate frontier and never scans the corpus.
7. **Question title anchors.** In graph mode, exact title mentions and conservative one-edit
   multi-token typos become graph seeds. A single-token alias is accepted only when it is
   the unambiguous base of a parenthetically disambiguated title. Earlier mentions receive
   slightly higher priority, then the usual directed companion expansion runs.
8. **Lazy owned memory.** Feedback is copied on construction and BM25F evidence materializes
   on first use. `remember()` and `forget()` remain exact incremental updates, while an
   unused store does not pay eager indexing cost.

This separates complementary signals instead of mixing incomparable raw score scales. The
candidate bound, generic linguistic classes and title-edit rule are product contracts, not
per-dataset switches.

The candidate-local policy lives in `omnifuse.lexical_rerank`, separate from index storage
and persistence. Its thresholds and weights are named once rather than scattered through
backend branches, and `pool` is the only configured candidate floor—there is no hidden
minimum that overrides a caller's smaller pool.

The complete iteration history, rejected variants and immutable artifacts are kept outside
this product overview in [`eval/README.md`](eval/README.md) and [`eval/results`](eval/results).

## Benchmark summary

The current canonical comparison uses the official synaptic-memory `v0.27.0` tag at
`836d53640e520c88910dd57e098167a4defe50d2`. Comparable retrieval tracks use the same
corpus, queries, relevance judgments, K and byte-identical scorer. Each artifact binds the
source, inputs, Python environment, isolated workers and postflight state.

| track | OmniFuse | synaptic-memory | verdict |
|---|---:|---:|---|
| Direct14, 2,269 queries | MRR@10 **0.7049**, nDCG@10 **0.6783** | MRR@10 0.6537, nDCG@10 0.6100 | five metrics **14/0/0**, Recall **13/0/1** by dataset |
| HotPotQA retrieval, 24 questions | Recall **0.9792**, nDCG **0.9483**, mean **3.83 ms** | Recall 0.7292, nDCG 0.6908, mean 60.43 ms | **11/0** aggregate; zero per-question quality losses |
| LongMemEval-S, 48 questions | MRR **0.8392**, nDCG **0.8643**, mean **60.22 ms** | MRR 0.6990, nDCG 0.6898, mean 241.61 ms | **8/0** quality and efficiency aggregate |
| Enterprise full-native | MRR **0.7689**, nDCG **0.7637**, mean **0.791 ms** | MRR 0.7467, nDCG 0.6649, mean 5.799 ms | quality, search and build win |
| local-Qwen answer E2E | correctness **0.7938**, mean generation **6,323 ms** | correctness 0.5450, mean generation 6,752 ms | **9/1** aggregate; stochastic p95 generation loses |

The scope is precise:

- Direct14 has no dataset-level loss, but 301/2,269 queries have at least one lower quality
  metric. Synaptic already reaches a metric ceiling on many queries, so “all datasets win”
  is not rewritten as “every query wins.”
- HotPotQA and LongMemEval have zero individual quality losses. LongMemEval still records
  five local efficiency losses even though all aggregate efficiency metrics win.
- Enterprise `full_native` preserves each product's native graph and memory semantics.
  The asymmetric `docs_only` diagnostic is excluded from the equal-input verdict.
- Answer generation is stochastic and is reported separately from deterministic retrieval.

Full protocols, every historical run, rejected variants and reproduction commands are in
[`eval/README.md`](eval/README.md). Machine-readable, write-once evidence is in
[`eval/results`](eval/results); the current artifacts are:

- [Direct14 v34](eval/results/direct_external14_synaptic_tag_v0.27.0_836d536_20260810_v34.json)
- [HotPotQA retrieval v34](eval/results/e2e_qa_retrieval_synaptic_tag_v0.27.0_836d536_20260810_v34.json)
- [LongMemEval v34](eval/results/longmemeval_retrieval_synaptic_tag_v0.27.0_836d536_20260810_v34.json)
- [Enterprise v34](eval/results/enterprise_synaptic_tag_v0.27.0_836d536_20260810_v34.json)
- [local-Qwen answer v26](eval/results/e2e_qa_answer_synaptic_tag_v0.27.0_836d536_qwen3.5_4b_20260809_v26.json)

## Memory — `Feedback`

The deepest difference between OmniFuse and synaptic-**memory** was never the ranking: it
is that synaptic is *stateful* and learns. Synaptic's own suite already tests reinforcement
and consolidation contracts; our narrower question was whether those updates improve
held-out `graph.search` retrieval. The first version told us we had won when we had not,
which is why the placebo controls are now part of the harness.

A confirmed query becomes **evidence about** a chunk: indexed as a BM25F evidence field
whose terms score it but never enter document frequency, and which is not
length-normalized.

```python
from omnifuse import Feedback, build_inmemory
fb = Feedback()
fb.remember("statin side effects", ["doc7"])          # a user confirmed doc7 answered it
of = build_inmemory(nodes, triples, chunks, feedback=fb)
```

The evidence field scores only the chunk that owns it. It does not enter document frequency
or length normalization, so a cold feedback-enabled store ranks identically to a store with
no feedback and unrelated chunks keep their content IDF.

### Learning without a rebuild

`remember(query, doc_ids)` and `forget(query, doc_ids)` update only the affected evidence
postings. Both directions are bit-identical to a full rebuild; the update cost follows the
changed memory rather than the corpus size.

```python
of = build_inmemory(nodes, triples, chunks, feedback=Feedback())
of.remember("statin side effects", ["doc7"])
of.forget("statin side effects", ["doc7"])
```

Held-out paraphrase, shuffled/random placebo, consolidation and incremental-cost experiments
are archived with their controls and retraction history in
[`eval/README.md`](eval/README.md), [`adaptive_memory.json`](eval/results/adaptive_memory.json)
and [`incremental_memory.json`](eval/results/incremental_memory.json).

## Roadmap

- `backends/qdrant.py` vector adapter; jena-text fast path for `FusekiGraph`
- async pipeline (parallel seeds via `asyncio.gather`)
- cross-encoder reranker hook, query expansion
- configurable ISA predicates (prompt templates landed in 0.6.0 as `prompt_builder=`)

## Vault — fuse / surface (omnifuse-native memory)

A growing knowledge store with two omnifuse-specific dynamics, not a generic remember/recall:
**fuse-on-write** (facts deduped & merged by entity) and **salience** (frequently fused/surfaced
nodes rank higher). Zero infra; notes auto-link to known entities; persists to JSONL.

```python
from omnifuse import Vault

v = Vault()
v.fuse(facts=[("담보", "instanceOf", "규정")])
v.fuse("담보 한도는 5억원이다", facts=[("담보", "한도", "5억")])
print(v.surface("담보 한도").answer)     # fusion search over everything fused, salience-ranked
v.save("vault.jsonl"); v2 = Vault.load("vault.jsonl")
```

## CI / Releasing

- `ci.yml` — runs pytest (3.10–3.12) + `python -m build` + `twine check` on every push/PR.
- `publish.yml` — on a GitHub **Release** in `PlateerLab/xgen-omnifuse`, runs the tests, builds
  and uploads to PyPI with `secrets.PYPI_API_TOKEN`. Any organization member with write access
  can release: bump `version` in `pyproject.toml`, add a CHANGELOG entry, tag `vX.Y.Z`, publish
  the release. The job is gated to the organization repository, so the mirror never publishes.

Build locally:

```bash
pip install build && python -m build      # dist/*.tar.gz + *.whl
```

## License

Source-available. Copyright (c) 2026 Jinsoo Kim.

Members of the PlateerLab organization may use, modify and ship it as part of Plateer products
(LICENSE §4). For anyone else, reading and citing are fine; any other use needs written
permission. See [`LICENSE`](LICENSE).
