Skip to content

anomaly_score_db

anomaly_match.prediction.anomaly_score_db

SQLite-backed prediction result database with resume support.

Single predictions.db per prediction run, storing (filename, score) pairs plus a run_metadata table used to validate that a resumed run targets the same model / normalisation as the original run.

Schema (schema_version=4):

  • results (id, filename, score): score is the 32-bit float bit pattern as an INTEGER (0..4294967295) — non-negative floats keep their ordering as unsigned ints, so ORDER BY score works. The rowid id gives insertion order (INSERT OR REPLACE bumps it), so ORDER BY id DESC is "most recently written" with no updated_at column.
  • run_metadata: flat key → json store for the schema version and the :meth:AnomalyScoreDB.validate_compatibility fields.

Opening an older-schema DB raises :class:SchemaVersionError (pre-release layout, not migrated).

SchemaVersionError

Bases: RuntimeError

Raised when opening a predictions.db written by an older schema.

AnomalyScoreDB(db_path, *, checkpoint_every_n_commits=_DEFAULT_CHECKPOINT_EVERY_N_COMMITS, synchronous_full=False, read_only=False, busy_timeout_ms=_BUSY_TIMEOUT_MS)

SQLite store for prediction results with resume support.

Concurrency contract

Uses WAL journal mode so the prediction subprocess (the single writer) and the UI process (readers) can operate concurrently without blocking. Implicit transactions use BEGIN IMMEDIATE (see :class:sqlite3.Connection.isolation_level) — the RESERVED lock is acquired at BEGIN time, before any work is done, so a contending writer usually waits out busy_timeout (30 s) or raises :class:sqlite3.OperationalError cleanly rather than racing another writer mid-transaction. busy_timeout does not cover a lock upgrade, though: a connection holding SHARED that tries to take RESERVED while another already holds it gets SQLITE_BUSY immediately. So read paths must issue only SELECT — no temp tables, no DML — otherwise isolation_level turns a "read" into a write transaction that contends for the single writer slot (this bit :meth:get_unprocessed, which is why it is SELECT-only).

Single writer per DB is the current invariant. IMMEDIATE + busy_timeout is the primitive a future multi-writer coordinator (issue #322) will extend: either a queue-based single writer draining per-GPU workers, or per-worker DBs merged via ATTACH. Either path drops in without schema change.

Parameters:

Name Type Description Default
db_path str | Path

Path to the SQLite database file. Created if missing.

required
checkpoint_every_n_commits int

Run PRAGMA wal_checkpoint(TRUNCATE) after this many :meth:store_results commits. Bounds the .wal file size on multi-day runs where a reader might otherwise keep the WAL from being folded into the main file. Defaults to 1000.

_DEFAULT_CHECKPOINT_EVERY_N_COMMITS
synchronous_full bool

If True, use PRAGMA synchronous=FULL (fsync after every commit — crash-safe but ~10% slower). Defaults to False (synchronous=NORMAL), which trusts the filesystem to fsync within the rollback-journal contract.

False
read_only bool

If True, open for queries only — skip the table-creation and schema-version writes so no write lock is taken. Used by the gallery's catalogue-index lookup (#506) so a read never contends with the live scoring writer. Defaults to False.

False
busy_timeout_ms int

PRAGMA busy_timeout for this connection, in milliseconds — how long a statement waits on a lock held by another connection before raising "database is locked". Lower it (e.g. 0) for a best-effort writer that should give up immediately under contention rather than block. Defaults to :data:_BUSY_TIMEOUT_MS.

_BUSY_TIMEOUT_MS

Raises:

Type Description
SchemaVersionError

If db_path exists and was written by an incompatible schema version.

Source code in anomaly_match/prediction/anomaly_score_db.py
def __init__(
    self,
    db_path: str | Path,
    *,
    checkpoint_every_n_commits: int = _DEFAULT_CHECKPOINT_EVERY_N_COMMITS,
    synchronous_full: bool = False,
    read_only: bool = False,
    busy_timeout_ms: int = _BUSY_TIMEOUT_MS,
) -> None:
    self._db_path = Path(db_path)
    self._db_path.parent.mkdir(parents=True, exist_ok=True)
    # Serialises _safe_execute against close() to prevent use-after-free
    # of the C-level sqlite3 handle (segfault on Linux).
    self._close_lock = threading.Lock()

    self._checkpoint_every_n_commits = checkpoint_every_n_commits
    self._synchronous_full = synchronous_full
    self._commits_since_checkpoint = 0
    # read_only skips the table-creation and schema-version writes below, so
    # opening for a query (e.g. the gallery's catalogue-index lookup, #506)
    # takes no write lock and never contends with the live scoring writer.
    # busy_timeout_ms can be lowered (e.g. 0) so a best-effort writer gives
    # up immediately under contention instead of blocking.
    self._busy_timeout_ms = busy_timeout_ms

    db_existed = self._db_path.exists()

    self._conn = sqlite3.connect(
        str(self._db_path),
        # check_same_thread=False allows the UI thread to read while
        # the prediction thread writes (WAL handles the concurrency).
        check_same_thread=False,
        # IMMEDIATE upgrades implicit BEGIN to BEGIN IMMEDIATE, which
        # acquires the RESERVED lock at transaction start. Two writers
        # contending for the DB then resolve via busy_timeout instead
        # of racing mid-transaction; see the class docstring.
        isolation_level="IMMEDIATE",
    )
    self._conn.row_factory = sqlite3.Row
    self._conn.create_function("fp32", 1, _fp32_decode, deterministic=True)

    self._configure_pragmas()
    if db_existed:
        self._assert_schema_version()
    if not read_only:
        self._create_tables()
        self._record_schema_version()

    logger.debug("AnomalyScoreDB opened at {}", self._db_path)

path property

Return the path to the database file.

Returns:

Type Description
Path

Database file path.

store_results(results)

Batch insert or update prediction results.

Scores are rounded to fp32 precision before storage. Existing rows with the same filename are replaced (INSERT OR REPLACE); replacement assigns a new rowid so the replaced row moves to the top of the updated_desc ordering.

The commit also bumps the last_heartbeat metadata key. WAL growth is bounded passively by PRAGMA wal_autocheckpoint; every checkpoint_every_n_commits commits an opportunistic PRAGMA wal_checkpoint(TRUNCATE) additionally tries to reset the .wal file to zero, which only succeeds when no reader holds it open. The checkpoint runs outside the transaction — it cannot execute inside one.

Parameters:

Name Type Description Default
results list[tuple[str, float]]

Sequence of (filename, score) tuples.

required

Raises:

Type Description
DatabaseError

If the score write fails for a non-transient reason. A transient WAL-checkpoint lock from a concurrent reader is caught (the WAL stays bounded by autocheckpoint), not raised.

Source code in anomaly_match/prediction/anomaly_score_db.py
def store_results(self, results: list[tuple[str, float]]) -> None:
    """Batch insert or update prediction results.

    Scores are rounded to fp32 precision before storage.  Existing
    rows with the same filename are replaced (``INSERT OR REPLACE``);
    replacement assigns a new rowid so the replaced row moves to the
    top of the ``updated_desc`` ordering.

    The commit also bumps the ``last_heartbeat`` metadata key. WAL
    growth is bounded passively by ``PRAGMA wal_autocheckpoint``; every
    ``checkpoint_every_n_commits`` commits an opportunistic
    ``PRAGMA wal_checkpoint(TRUNCATE)`` additionally tries to reset the
    .wal file to zero, which only succeeds when no reader holds it open.
    The checkpoint runs *outside* the transaction — it cannot execute
    inside one.

    Args:
        results: Sequence of ``(filename, score)`` tuples.

    Raises:
        sqlite3.DatabaseError: If the score write fails for a non-transient
            reason. A transient WAL-checkpoint lock from a concurrent reader
            is caught (the WAL stays bounded by autocheckpoint), not raised.
    """
    if not results:
        return

    rows = [(fn, _fp32_encode(score)) for fn, score in results]
    now = int(time.time())
    cur = self._conn.cursor()
    cur.executemany(
        "INSERT OR REPLACE INTO results(filename, score) VALUES (?, ?)",
        rows,
    )
    # Heartbeat in the same transaction as the scores — any reader
    # seeing a fresh heartbeat also sees the scores it advertises.
    cur.execute(
        "INSERT OR REPLACE INTO run_metadata(key, value) VALUES (?, ?)",
        (_HEARTBEAT_KEY, json.dumps(now)),
    )
    self._conn.commit()

    self._commits_since_checkpoint += 1
    if self._commits_since_checkpoint >= self._checkpoint_every_n_commits:
        self._commits_since_checkpoint = 0
        # Opportunistic shrink only. wal_autocheckpoint already bounds WAL
        # growth passively, so this TRUNCATE just resets the file to zero
        # when no reader holds it open. Under the live UI poller it usually
        # returns busy=1 and shrinks nothing — harmless, the WAL stays at its
        # bounded high-water mark until a quieter moment. Must never crash
        # the prediction write loop.
        #
        # Critically, drop busy_timeout to 0 for the duration: TRUNCATE
        # respects busy_timeout, so at the default 30 s it blocks the write
        # loop for the *full* 30 s every cadence interval while the gallery
        # poller holds a reader snapshot (measured: ~30 s spikes once per 25
        # commits, ~16% of prediction wall and the gap below peak img/s). At
        # 0 it gives up instantly when a reader is active and still shrinks
        # in the genuine quiet gaps. wal_autocheckpoint bounds the WAL either
        # way, so giving up costs nothing.
        try:
            cur.execute("PRAGMA busy_timeout=0")
            # Row is (busy, log_frames, checkpointed_frames); busy=1 means a
            # reader kept the WAL open so it could not be reset to zero.
            row = cur.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
            if row is not None and row[0]:
                logger.debug(
                    "WAL truncate blocked by reader (busy); WAL bounded by autocheckpoint"
                )
        except sqlite3.DatabaseError as exc:
            # A reader can block the WAL restart as either "database is
            # locked" (OperationalError) or "database table is locked"
            # (base DatabaseError). Both are transient — autocheckpoint keeps
            # the WAL bounded regardless. Anything else is a real error.
            if "locked" not in str(exc).lower():
                raise
            logger.debug("WAL checkpoint deferred (reader active): {}", exc)
        finally:
            cur.execute(f"PRAGMA busy_timeout={self._busy_timeout_ms}")

record_catalogues(source_to_catalogue)

Record which catalogue file each scored source came from (#506).

Interns each distinct catalogue path into the catalogues table and points the matching results rows at it via catalogue_idx, so a later gallery load reads only the catalogue that holds a source instead of scanning them all. Sources without a results row (not scored) are silently ignored — only scored sources are ever browsed.

Parameters:

Name Type Description Default
source_to_catalogue dict[str, str]

Map from source_id (the results.filename key) to the absolute catalogue path it was found in. An empty map is a no-op; callers only record sources they actually found.

required
Source code in anomaly_match/prediction/anomaly_score_db.py
def record_catalogues(self, source_to_catalogue: dict[str, str]) -> None:
    """Record which catalogue file each scored source came from (#506).

    Interns each distinct catalogue path into the ``catalogues`` table and
    points the matching ``results`` rows at it via ``catalogue_idx``, so a
    later gallery load reads only the catalogue that holds a source instead
    of scanning them all.  Sources without a ``results`` row (not scored)
    are silently ignored — only scored sources are ever browsed.

    Args:
        source_to_catalogue: Map from ``source_id`` (the ``results.filename``
            key) to the absolute catalogue path it was found in.  An empty
            map is a no-op; callers only record sources they actually found.
    """
    cur = self._conn.cursor()
    # Intern the distinct paths first; INSERT OR IGNORE keeps existing
    # indices stable so previously-recorded rows stay valid.  One
    # executemany rather than a Python-level loop of single executes.
    distinct_paths = {str(p) for p in source_to_catalogue.values()}
    cur.executemany(
        "INSERT OR IGNORE INTO catalogues(path) VALUES (?)",
        [(path,) for path in distinct_paths],
    )
    path_to_idx = {
        row["path"]: row["idx"] for row in cur.execute("SELECT idx, path FROM catalogues")
    }
    cur.executemany(
        "UPDATE results SET catalogue_idx = ? WHERE filename = ?",
        [(path_to_idx[str(cat)], str(sid)) for sid, cat in source_to_catalogue.items()],
    )
    self._conn.commit()

get_catalogue_map(source_ids)

Return the recorded catalogue path for each given scored source (#506).

Parameters:

Name Type Description Default
source_ids list[str]

Source ids to look up.

required

Returns:

Type Description
dict[str, str]

Map from source_id to its catalogue path, containing only the

dict[str, str]

ids whose catalogue_idx has been recorded (others fall back to a

dict[str, str]

full catalogue scan in the loader). An empty input yields an empty

dict[str, str]

map.

Source code in anomaly_match/prediction/anomaly_score_db.py
def get_catalogue_map(self, source_ids: list[str]) -> dict[str, str]:
    """Return the recorded catalogue path for each given scored source (#506).

    Args:
        source_ids: Source ids to look up.

    Returns:
        Map from ``source_id`` to its catalogue path, containing only the
        ids whose ``catalogue_idx`` has been recorded (others fall back to a
        full catalogue scan in the loader).  An empty input yields an empty
        map.
    """
    wanted = [str(s) for s in source_ids]
    rows = self._select_in_chunks(
        "SELECT r.filename AS filename, c.path AS path "
        "FROM results r JOIN catalogues c ON r.catalogue_idx = c.idx "
        "WHERE r.filename IN ({placeholders})",
        wanted,
    )
    return {row["filename"]: row["path"] for row in rows}

set_metadata(key, value)

Store a metadata key-value pair (JSON-serialised).

Parameters:

Name Type Description Default
key str

Metadata key (e.g. "model_path").

required
value Any

Arbitrary JSON-serialisable value.

required
Source code in anomaly_match/prediction/anomaly_score_db.py
def set_metadata(self, key: str, value: Any) -> None:
    """Store a metadata key-value pair (JSON-serialised).

    Args:
        key: Metadata key (e.g. ``"model_path"``).
        value: Arbitrary JSON-serialisable value.
    """
    self._conn.execute(
        "INSERT OR REPLACE INTO run_metadata(key, value) VALUES (?, ?)",
        (key, json.dumps(value)),
    )
    self._conn.commit()

set_metadata_batch(items)

Store multiple metadata key-value pairs in one transaction.

Parameters:

Name Type Description Default
items dict[str, Any]

Mapping of metadata keys to JSON-serialisable values.

required
Source code in anomaly_match/prediction/anomaly_score_db.py
def set_metadata_batch(self, items: dict[str, Any]) -> None:
    """Store multiple metadata key-value pairs in one transaction.

    Args:
        items: Mapping of metadata keys to JSON-serialisable values.
    """
    self._conn.executemany(
        "INSERT OR REPLACE INTO run_metadata(key, value) VALUES (?, ?)",
        [(k, json.dumps(v)) for k, v in items.items()],
    )
    self._conn.commit()

get_results(sort_by='score_desc', limit=100, offset=0)

Query results with sorting and pagination.

Parameters:

Name Type Description Default
sort_by str

One of "score_desc", "score_asc", "score_mean_dist", "score_median_dist", "updated_desc", "updated_asc", "random".

'score_desc'
limit int

Maximum number of rows to return.

100
offset int

Number of rows to skip.

0

Returns:

Type Description
list[dict]

List of dicts with keys filename and score.

Raises:

Type Description
ValueError

If sort_by is not a recognised sort mode.

Source code in anomaly_match/prediction/anomaly_score_db.py
def get_results(
    self,
    sort_by: str = "score_desc",
    limit: int = 100,
    offset: int = 0,
) -> list[dict]:
    """Query results with sorting and pagination.

    Args:
        sort_by: One of ``"score_desc"``, ``"score_asc"``,
            ``"score_mean_dist"``, ``"score_median_dist"``,
            ``"updated_desc"``, ``"updated_asc"``, ``"random"``.
        limit: Maximum number of rows to return.
        offset: Number of rows to skip.

    Returns:
        List of dicts with keys ``filename`` and ``score``.

    Raises:
        ValueError: If *sort_by* is not a recognised sort mode.
    """
    order = _SORT_CLAUSES.get(sort_by)
    if order is None:
        raise ValueError(f"Unknown sort_by={sort_by!r}. Valid options: {list(_SORT_CLAUSES)}")

    # Safe: sort_by is validated against _SORT_CLAUSES allowlist
    rows = self._safe_execute(
        f"""
        SELECT r.filename, r.score
        FROM results r
        ORDER BY {order}
        LIMIT ? OFFSET ?
        """,
        (limit, offset),
    )

    return [
        {
            "filename": row["filename"],
            "score": _fp32_decode(row["score"]),
        }
        for row in rows
    ]

get_top_results(n)

Convenience wrapper: return the n highest-scoring results.

Parameters:

Name Type Description Default
n int

Number of top results to return.

required

Returns:

Type Description
list[dict]

List of result dicts sorted by descending score.

Source code in anomaly_match/prediction/anomaly_score_db.py
def get_top_results(self, n: int) -> list[dict]:
    """Convenience wrapper: return the *n* highest-scoring results.

    Args:
        n: Number of top results to return.

    Returns:
        List of result dicts sorted by descending score.
    """
    return self.get_results(sort_by="score_desc", limit=n, offset=0)

get_count()

Return total number of stored results.

Returns:

Type Description
int

Row count of the results table.

Source code in anomaly_match/prediction/anomaly_score_db.py
def get_count(self) -> int:
    """Return total number of stored results.

    Returns:
        Row count of the results table.
    """
    rows = self._safe_execute("SELECT COUNT(*) AS cnt FROM results")
    return rows[0]["cnt"] if rows else 0

get_result_by_filename(filename)

Look up a single result by its filename.

Parameters:

Name Type Description Default
filename str

The source filename to look up.

required

Returns:

Type Description
dict | None

Dict with keys filename and score, or None if

dict | None

not found.

Source code in anomaly_match/prediction/anomaly_score_db.py
def get_result_by_filename(self, filename: str) -> dict | None:
    """Look up a single result by its filename.

    Args:
        filename: The source filename to look up.

    Returns:
        Dict with keys ``filename`` and ``score``, or ``None`` if
        not found.
    """
    rows = self._safe_execute(
        """
        SELECT r.filename, r.score
        FROM results r
        WHERE r.filename = ?
        """,
        (filename,),
    )
    if not rows:
        logger.debug("No result found for filename: %s", filename)
        return None
    row = rows[0]
    return {
        "filename": row["filename"],
        "score": _fp32_decode(row["score"]),
    }

get_all_scores()

Return every stored score as a float32 numpy array.

Loads the entire score column into memory — not suitable for databases with more than a few million rows.

Returns:

Type Description
ndarray

1-D array of all scores.

Source code in anomaly_match/prediction/anomaly_score_db.py
def get_all_scores(self) -> np.ndarray:
    """Return every stored score as a float32 numpy array.

    Loads the entire ``score`` column into memory — not suitable for
    databases with more than a few million rows.

    Returns:
        1-D array of all scores.
    """
    rows = self._safe_execute("SELECT score FROM results")
    if not rows:
        return np.empty(0, dtype=np.float32)
    # Both dtypes are pinned little-endian so the pair can never drift
    # apart.  Native/native would be equally correct — the value arrives
    # from SQLite as a Python int, so there is no on-the-wire byte order to
    # match; mixing the two views is the only thing that corrupts the
    # reinterpretation.
    raw = np.fromiter((r["score"] for r in rows), dtype="<u4", count=len(rows))
    return raw.view("<f4")

get_score_range()

Return (min_score, max_score) across all results.

Returns:

Type Description
tuple[float, float]

Tuple of minimum and maximum score values.

Raises:

Type Description
ValueError

If the database contains no results.

Source code in anomaly_match/prediction/anomaly_score_db.py
def get_score_range(self) -> tuple[float, float]:
    """Return ``(min_score, max_score)`` across all results.

    Returns:
        Tuple of minimum and maximum score values.

    Raises:
        ValueError: If the database contains no results.
    """
    rows = self._safe_execute("SELECT MIN(score) AS lo, MAX(score) AS hi FROM results")
    if not rows or rows[0]["lo"] is None:
        raise ValueError("No results in database")
    return (_fp32_decode(rows[0]["lo"]), _fp32_decode(rows[0]["hi"]))

get_score_histogram(bins=50)

Compute a score histogram entirely in SQL.

Parameters:

Name Type Description Default
bins int

Number of histogram bins (default 50).

50

Returns:

Type Description
ndarray

(counts, bin_edges) — counts has length bins,

ndarray

bin_edges has length bins + 1. Empty bins have

tuple[ndarray, ndarray]

count 0.

Source code in anomaly_match/prediction/anomaly_score_db.py
def get_score_histogram(self, bins: int = 50) -> tuple[np.ndarray, np.ndarray]:
    """Compute a score histogram entirely in SQL.

    Args:
        bins: Number of histogram bins (default 50).

    Returns:
        ``(counts, bin_edges)`` — *counts* has length *bins*,
        *bin_edges* has length ``bins + 1``.  Empty bins have
        count 0.
    """
    # Use SQL to bin scores into [0, 1] range.
    # Scores are stored as fp32 bit patterns; decode via the fp32 UDF
    # before multiplying by the bin count.  Scores exactly equal to
    # 1.0 are clamped into the last bin.
    rows = self._safe_execute(
        """
        SELECT
            CASE
                WHEN CAST(fp32(score) * ? AS INTEGER) >= ? THEN ? - 1
                ELSE CAST(fp32(score) * ? AS INTEGER)
            END AS bin,
            COUNT(*) AS cnt
        FROM results
        GROUP BY bin
        ORDER BY bin
        """,
        (bins, bins, bins, bins),
    )

    counts = np.zeros(bins, dtype=np.int64)
    for row in rows:
        idx = int(row["bin"])
        if 0 <= idx < bins:
            counts[idx] = row["cnt"]

    bin_edges = np.linspace(0.0, 1.0, bins + 1)
    return counts, bin_edges

is_processed(filename)

Check whether filename already has a stored result.

Parameters:

Name Type Description Default
filename str

Source filename to check.

required

Returns:

Type Description
bool

True if a result row exists for this filename.

Source code in anomaly_match/prediction/anomaly_score_db.py
def is_processed(self, filename: str) -> bool:
    """Check whether *filename* already has a stored result.

    Args:
        filename: Source filename to check.

    Returns:
        ``True`` if a result row exists for this filename.
    """
    row = self._conn.execute("SELECT 1 FROM results WHERE filename = ?", (filename,)).fetchone()
    return row is not None

get_unprocessed(filenames)

Filter filenames to only those without stored results.

SELECT-only (no temp tables); input order and any duplicate entries are preserved.

Parameters:

Name Type Description Default
filenames list[str]

Candidate filenames to check.

required

Returns:

Type Description
list[str]

Subset of filenames that do not yet appear in the

list[str]

results table.

Source code in anomaly_match/prediction/anomaly_score_db.py
def get_unprocessed(self, filenames: list[str]) -> list[str]:
    """Filter *filenames* to only those without stored results.

    SELECT-only (no temp tables); input order and any duplicate entries are
    preserved.

    Args:
        filenames: Candidate filenames to check.

    Returns:
        Subset of *filenames* that do not yet appear in the
        results table.
    """
    if not filenames:
        return []

    # SELECT-only so this resume check takes no write lock. The previous
    # temp-table approach (CREATE/INSERT/DELETE) opened an IMMEDIATE write
    # transaction (the connection's isolation_level) and contended with the
    # live scoring writer for the single WAL writer slot — a lock-upgrade
    # collision then returns SQLITE_BUSY immediately, which busy_timeout
    # can't wait out, and killed a prediction chunk.
    rows = self._select_in_chunks(
        "SELECT filename FROM results WHERE filename IN ({placeholders})", filenames
    )
    processed = {row["filename"] for row in rows}
    return [f for f in filenames if f not in processed]

get_processed_filenames()

Return the set of all processed filenames.

For very large databases this loads all filenames into memory. Prefer :meth:get_unprocessed for incremental resume checks.

Returns:

Type Description
set[str]

Set of filenames with stored results.

Source code in anomaly_match/prediction/anomaly_score_db.py
def get_processed_filenames(self) -> set[str]:
    """Return the set of all processed filenames.

    For very large databases this loads all filenames into memory.
    Prefer :meth:`get_unprocessed` for incremental resume checks.

    Returns:
        Set of filenames with stored results.
    """
    rows = self._conn.execute("SELECT filename FROM results").fetchall()
    return {row["filename"] for row in rows}

validate_compatibility(cfg)

Check whether cfg matches the stored run metadata.

Only keys listed in :data:_COMPAT_KEYS are compared. The schema-version entry is always present (written on open) but is not part of the compatibility check — a schema mismatch is already rejected in :meth:__init__.

cfg is the prediction config; this method calls :func:build_compat_metadata internally so all DB-related metadata extraction lives next to the DB. Callers that already hold a built dict can use :meth:set_metadata_batch directly.

Parameters:

Name Type Description Default
cfg DotMap

Prediction config with model_path, net, and normalisation populated.

required

Returns:

Type Description
bool

(is_compatible, message) — message describes the first

str

mismatch found, or is empty on success. A fresh DB (no

tuple[bool, str]

compat keys stored yet) is always compatible.

Source code in anomaly_match/prediction/anomaly_score_db.py
def validate_compatibility(self, cfg: DotMap) -> tuple[bool, str]:
    """Check whether *cfg* matches the stored run metadata.

    Only keys listed in :data:`_COMPAT_KEYS` are compared.  The
    schema-version entry is always present (written on open) but is
    not part of the compatibility check — a schema mismatch is
    already rejected in :meth:`__init__`.

    ``cfg`` is the prediction config; this method calls
    :func:`build_compat_metadata` internally so all DB-related
    metadata extraction lives next to the DB.  Callers that already
    hold a built dict can use :meth:`set_metadata_batch` directly.

    Args:
        cfg: Prediction config with ``model_path``, ``net``, and
            ``normalisation`` populated.

    Returns:
        ``(is_compatible, message)`` — *message* describes the first
        mismatch found, or is empty on success.  A fresh DB (no
        compat keys stored yet) is always compatible.
    """
    cfg_metadata = build_compat_metadata(cfg)
    stored = self.get_metadata()

    for key, label in _COMPAT_KEYS.items():
        if key not in stored:
            logger.debug("Compatibility key %r not in stored metadata; skipping", key)
            continue
        if stored[key] != cfg_metadata[key]:
            return (
                False,
                f"{label} mismatch: stored={stored[key]!r}, current={cfg_metadata[key]!r}",
            )

    return True, ""

get_metadata()

Return all stored metadata as a dict.

Returns:

Type Description
dict[str, Any]

Mapping of metadata keys to their JSON-deserialised values.

Source code in anomaly_match/prediction/anomaly_score_db.py
def get_metadata(self) -> dict[str, Any]:
    """Return all stored metadata as a dict.

    Returns:
        Mapping of metadata keys to their JSON-deserialised values.
    """
    rows = self._conn.execute("SELECT key, value FROM run_metadata").fetchall()
    result = {row["key"]: json.loads(row["value"]) for row in rows}
    if not result:
        logger.debug("No metadata found in prediction DB")
    return result

get_heartbeat()

Return the unix-epoch timestamp of the last successful commit.

Refreshed by :meth:store_results in the same transaction as the scores. A stale value (more than a few minutes behind the wall clock) means the writer subprocess is paused or crashed.

Returns:

Type Description
int | None

Integer unix timestamp, or None if no batch has committed

int | None

since the DB was created.

Source code in anomaly_match/prediction/anomaly_score_db.py
def get_heartbeat(self) -> int | None:
    """Return the unix-epoch timestamp of the last successful commit.

    Refreshed by :meth:`store_results` in the same transaction as
    the scores.  A stale value (more than a few minutes behind the
    wall clock) means the writer subprocess is paused or crashed.

    Returns:
        Integer unix timestamp, or ``None`` if no batch has committed
        since the DB was created.
    """
    rows = self._safe_execute("SELECT value FROM run_metadata WHERE key = ?", (_HEARTBEAT_KEY,))
    if not rows:
        return None
    return int(json.loads(rows[0]["value"]))

close()

Close the database connection.

Acquires _close_lock to wait for any in-flight _safe_execute calls to finish, then nulls _conn so subsequent reads return []. The actual conn.close() runs after the lock is released — at that point no thread can hold a reference to the connection.

On the way out:

  • PRAGMA wal_checkpoint(TRUNCATE) folds the WAL back into the main file so a reopened DB does not carry a *.wal leftover from the last run.
  • PRAGMA optimize lets SQLite regenerate statistics on indexes that have changed since they were last ANALYZEd.

A best-effort wrapper swallows per-pragma errors so a write lock held by something else (or a broken connection) can't block shutdown.

Source code in anomaly_match/prediction/anomaly_score_db.py
def close(self) -> None:
    """Close the database connection.

    Acquires ``_close_lock`` to wait for any in-flight
    ``_safe_execute`` calls to finish, then nulls ``_conn`` so
    subsequent reads return ``[]``.  The actual ``conn.close()``
    runs after the lock is released — at that point no thread can
    hold a reference to the connection.

    On the way out:

    * ``PRAGMA wal_checkpoint(TRUNCATE)`` folds the WAL back into
      the main file so a reopened DB does not carry a ``*.wal``
      leftover from the last run.
    * ``PRAGMA optimize`` lets SQLite regenerate statistics on
      indexes that have changed since they were last ANALYZEd.

    A best-effort wrapper swallows per-pragma errors so a write
    lock held by something else (or a broken connection) can't
    block shutdown.
    """
    with self._close_lock:
        conn = self._conn
        self._conn = None
        if conn is not None:
            for stmt in ("PRAGMA wal_checkpoint(TRUNCATE)", "PRAGMA optimize"):
                try:
                    conn.execute(stmt)
                except sqlite3.Error as exc:
                    logger.debug("{} failed during close: {}", stmt, exc)
    if conn is not None:
        conn.close()
    logger.debug("AnomalyScoreDB closed: {}", self._db_path)

compute_model_sha256(path)

Return the hex SHA-256 digest of a model checkpoint file.

Used by both the prediction subprocesses (when writing model_sha256 into run_metadata) and the UI resume check (when asking whether an existing DB is compatible with the currently selected model). Content-hashing sidesteps the fragility of comparing model_path strings — relative vs absolute paths, symlinks, and cross-machine moves all collapse into the same hash.

Streamed in 1 MiB chunks so a multi-GB checkpoint doesn't have to fit in memory.

Parameters:

Name Type Description Default
path str | Path

Filesystem path to the checkpoint file.

required

Returns:

Type Description
str

Hex digest string (64 characters).

Source code in anomaly_match/prediction/anomaly_score_db.py
def compute_model_sha256(path: str | Path) -> str:
    """Return the hex SHA-256 digest of a model checkpoint file.

    Used by both the prediction subprocesses (when writing
    ``model_sha256`` into ``run_metadata``) and the UI resume check
    (when asking whether an existing DB is compatible with the
    currently selected model).  Content-hashing sidesteps the
    fragility of comparing ``model_path`` strings — relative vs
    absolute paths, symlinks, and cross-machine moves all collapse
    into the same hash.

    Streamed in 1 MiB chunks so a multi-GB checkpoint doesn't have to
    fit in memory.

    Args:
        path: Filesystem path to the checkpoint file.

    Returns:
        Hex digest string (64 characters).
    """
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()

build_compat_metadata(cfg)

Build the run_metadata dict for a prediction config.

Centralises the model / normalisation fields that are stored in run_metadata and consulted by :meth:AnomalyScoreDB.validate_compatibility. model_sha256 (not model_path) is the identity key the compatibility check uses: two runs whose cfg.model_path strings differ but whose files have the same bytes are compatible, and two runs whose strings match but whose files differ are not.

Parameters:

Name Type Description Default
cfg DotMap

Prediction config with model_path, net, and normalisation populated.

required

Returns:

Name Type Description
dict[str, Any]

Metadata dict suitable for :meth:AnomalyScoreDB.set_metadata_batch

and dict[str, Any]

meth:AnomalyScoreDB.validate_compatibility.

Source code in anomaly_match/prediction/anomaly_score_db.py
def build_compat_metadata(cfg: DotMap) -> dict[str, Any]:
    """Build the run_metadata dict for a prediction config.

    Centralises the model / normalisation fields that are stored in
    ``run_metadata`` and consulted by
    :meth:`AnomalyScoreDB.validate_compatibility`.  ``model_sha256`` (not
    ``model_path``) is the identity key the compatibility check uses:
    two runs whose ``cfg.model_path`` strings differ but whose files
    have the same bytes are compatible, and two runs whose strings
    match but whose files differ are not.

    Args:
        cfg: Prediction config with ``model_path``, ``net``, and
            ``normalisation`` populated.

    Returns:
        Metadata dict suitable for :meth:`AnomalyScoreDB.set_metadata_batch`
        and :meth:`AnomalyScoreDB.validate_compatibility`.
    """
    return {
        "model_path": cfg.model_path,
        "model_sha256": compute_model_sha256(cfg.model_path),
        "net": cfg.net,
        "image_size": list(cfg.normalisation.image_size),
        "normalisation_method": str(cfg.normalisation.normalisation_method),
        "n_output_channels": cfg.normalisation.n_output_channels,
    }