Skip to main content
Glama
J-X0
by J-X0
README.md
# netherbymultimodal (Project Netherby)

Air-gapped multimodal document processing for Harrow Risk. It fuses **document
layout parsing** with **text extraction**: given a page's extracted text spans
and the layout regions detected on that page, it produces ordered, labelled
reading blocks and reconstructed text.

The defining constraint is that it runs **fully air-gapped**. Nothing reaches the
network at runtime. That is enforced in code (`netherbymultimodal/airgap.py`) and
proven by the test suite, not just asserted in prose.

## Layout

```
netherbymultimodal/
  __init__.py        public API: fuse_page, fuse_document, domain types
  types.py           BBox, TextSpan, LayoutRegion, Page, FusedBlock, FusedPage
  airgap.py          no_network(): blocks sockets/DNS for a code section
  fusion.py          the core algorithm (assignment, ordering, reconstruction)
  server.py          MCP server over stdio (JSON-RPC 2.0)
  service.py         validate payload -> fuse -> plain data; timing + logging
  config.py          Config with env overrides and validation
  logging.py         structured JSON logging to stderr
  errors.py          error taxonomy (InvalidInputError, ResourceLimitError)
  providers/
    base.py          LayoutProvider interface + ProviderUnavailable
    stub.py          deterministic, dependency-free provider (tests + offline)
    real.py          local-ONNX provider (loads under the air-gap guard)
tests/
```

## The algorithm

`fuse_page(page, regions)` in `fusion.py`:

1. **Assignment** — each text span is attached to the region that contains the
   largest fraction of the span's area, above a threshold (`min_containment`).
   Containment, not IoU: a small span inside a large region should score 1.0.
2. **Orphans** — spans no region claims are grouped by proximity into their own
   blocks. Extracted text is never silently dropped (an audit requirement).
3. **Intra-block order** — spans are grouped into visual lines (by vertical
   centre) and ordered top-to-bottom, then left-to-right within a line.
4. **Inter-block order** — blocks are grouped into columns by left-edge
   clustering and read column-by-column, so two-column pages read correctly.

`fuse_document(pages, provider)` sources regions from a `LayoutProvider` and runs
the whole thing inside `no_network()`.

## Providers

All model behaviour goes through `LayoutProvider` so the core never depends on a
model and the suite runs offline with no API key.

- `StubLayoutProvider` — derives regions from span geometry. Deterministic,
  no dependencies. Default for offline runs and the basis of the tests.
- `OnnxLayoutProvider` — loads a local ONNX detector. The model must already be
  on disk; there is no download path. Loading happens inside `no_network()`, so
  any library that tries to fetch weights or send telemetry fails loudly. The
  tensor inference is a marked seam (`real.py` TODO) pending Harrow Risk's model
  choice; a missing or unloadable model raises `ProviderUnavailable` rather than
  returning an empty layout.

## Threat model (air-gap)

`no_network()` patches the standard `socket` API (`socket`, `create_connection`,
`getaddrinfo`, `gethostbyname[_ex]`). It stops accidental egress from our code
and from libraries that use standard sockets. It is defence in depth, not a
sandbox: it does not contain code that bypasses `socket` or calls the OS
directly. Production deployment still isolates the process at the network layer.

## Quick start

```
make venv
make install
make test
```

`make` uses `PY ?= .venv/bin/python`; override it, e.g. `make test PY=python3.12`.

## MCP server

The entry point is an MCP server speaking JSON-RPC 2.0 over stdio, one JSON
object per line. Logs go to stderr so they never corrupt the protocol stream on
stdout. No socket is opened.

```
make run
# or: python -m netherbymultimodal.server
```

Methods: `initialize`, `tools/list`, and `tools/call` for the one tool,
`fuse_document`. Example exchange (request in, response out):

```json
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"fuse_document","arguments":{"pages":[{"width":600,"height":800,"spans":[{"text":"Certificate of Insurance","bbox":[50,20,300,40]}]}]}}}
```

The result's `content[0].text` is a JSON string with `provider`, `elapsed_ms`,
and per-page `blocks`.

### Configuration

Read from the environment (`NETHERBY_` prefix); all values are validated at
startup and a bad value exits non-zero with a message.

| Variable | Default | Meaning |
| --- | --- | --- |
| `NETHERBY_PROVIDER` | `stub` | `stub` or `onnx` |
| `NETHERBY_MODEL_PATH` | — | required when provider is `onnx` |
| `NETHERBY_ALLOW_PROVIDER_FALLBACK` | `true` | degrade to stub if the model won't load |
| `NETHERBY_MAX_PAGES` | `500` | reject payloads with more pages |
| `NETHERBY_MAX_SPANS_PER_PAGE` | `50000` | reject payloads with more spans per page |
| `NETHERBY_MAX_TEXT_LEN` | `100000` | reject spans with longer text |
| `NETHERBY_MIN_CONTAINMENT` | `0.5` | span->region assignment threshold |
| `NETHERBY_LOG_LEVEL` | `info` | `debug`/`info`/`warning`/`error` |

### Failure handling and degradation

- Malformed payloads (wrong types, non-finite numbers, inverted boxes, missing
  fields) return JSON-RPC `-32602` with a message pointing at the offending
  path; they never crash the loop.
- Payloads over a configured limit return `-32602` as a `ResourceLimitError`.
- Unparseable input lines return `-32700` and the server keeps serving.
- If the `onnx` provider cannot load or run (missing/unstaged model — the
  expected air-gapped case), the service logs a degradation record and falls
  back to the deterministic stub, unless `ALLOW_PROVIDER_FALLBACK` is false.

### Using it as a library

```python
from netherbymultimodal import fuse_document
from netherbymultimodal.providers.stub import StubLayoutProvider
from netherbymultimodal.types import BBox, Page, TextSpan

page = Page(number=1, width=600, height=800, spans=[
    TextSpan("Certificate of Insurance", BBox(50, 20, 300, 40)),
    TextSpan("This policy covers the named insured.", BBox(50, 100, 320, 115)),
])
fused = fuse_document([page], StubLayoutProvider())
for block in fused[0].blocks:
    print(block.reading_order, block.label, repr(block.text))
```

## Development notes

- No third-party runtime dependencies. `pytest` is the only dev dependency;
  `onnxruntime` is an optional extra (`pip install -e '.[real]'`) for the real
  provider.
- Known limit: full-width elements (banners, page-spanning tables) are bucketed
  into the leftmost column during column detection. See the TODO in
  `fusion.py` (`_detect_columns`).
- The `onnx` provider's tensor inference is not wired up (see the TODO in
  `providers/real.py`); loading and air-gap enforcement are done, inference
  awaits a model choice. Use the stub provider until then.

## Design decisions

Architecture decision records for the contested calls live in
[`docs/adr/`](docs/adr/README.md): in-process air-gap enforcement, containment
vs. IoU for assignment, hand-rolled JSON-RPC, and provider degradation.

---

*Harrow Risk is an illustrative client; this repository is a self-directed reference implementation built to work end to end.*

TDQS

A3.5/5.0

Scored across 1 tool

Disambiguation5/5

With only one tool, there is no possibility of overlapping purposes or misselection. The single tool's purpose is clearly stated.

Naming Consistency4/5

The single tool name 'fuse_document' follows a clear verb_noun snake_case convention and is readable. However, one tool alone does not provide enough surface to fully verify a naming pattern.

Tool Count2/5

For a server named 'multimodal', a single tool feels too few for the implied scope. One focused fusion step may be useful, but the server's naming suggests a broader tool surface is expected.

Completeness2/5

The server only offers a single fusion operation and lacks supporting tools for extraction, layout analysis, document management, or other multimodal workflows. This creates significant workflow gaps and likely dead ends for an agent.

Maintenance

ActivityInactive
ResponsivenessNo issues