morletdb

Install and query MorletDB

MorletDB is a developer-alpha PostgreSQL extension; the current release is 0.3.14-alpha.1. The SQL extension is named pg_morlet. Run these examples in a new disposable database; application roles should receive only the grants they need.

Fastest start: the release image

Each release publishes a prebuilt image, ghcr.io/merkhet-io/morletdb:<version>: PostgreSQL 17.6 (Debian bookworm) with pg_morlet installed, for Linux amd64 and arm64, with an SBOM per architecture and build provenance (releases). Nothing is compiled. Choose your own password; the port is bound to localhost and the data is kept in the named volume morletdb-data.

sh
docker run -d --name morletdb -e POSTGRES_USER=morlet -e POSTGRES_PASSWORD=change-me \
  -e POSTGRES_DB=morlet -p 127.0.0.1:54329:5432 -v morletdb-data:/var/lib/postgresql/data \
  ghcr.io/merkhet-io/morletdb:0.3.14-alpha.1
docker exec morletdb psql -U morlet -d morlet -c "CREATE EXTENSION pg_morlet"

The image does not include pgvector, so probe search needs a source installation or an image you build on top of it; exact and screened search work as they are. A newer image does not upgrade the SQL objects of an existing database: run ALTER EXTENSION pg_morlet UPDATE after switching images. The update to 0.3.8-alpha.1 (SQL 0.3.6) also pins the sample and derived columns of existing collections to pglz TOAST compression; it changes the catalog only and does not rewrite stored values.

Install on an existing server: Debian packages

From 0.3.5-alpha.1, each release attaches Debian packages for servers that run the PostgreSQL project’s packages (apt.postgresql.org): postgresql-17-pg-morlet and postgresql-18-pg-morlet, for amd64 and arm64, built on Debian bookworm. Before release, each one is installed with apt into a clean official postgres image and runs the quickstart, a restart and the quickstart again. Download the package for your server and SHA256SUMS from the release, then:

sh
sha256sum -c SHA256SUMS --ignore-missing
gh attestation verify postgresql-17-pg-morlet_<version>_<arch>.deb --owner merkhet-io
sudo apt install ./postgresql-17-pg-morlet_<version>_<arch>.deb
psql -d your_database -c "CREATE EXTENSION pg_morlet"

The file names use . where the package version has ~ (0.3.5.alpha.1 for version 0.3.5~alpha.1-1), because GitHub release assets cannot contain ~. The packages are trialed only on Debian bookworm images; other distributions are not tested. pgvector, for probe search, is a separate package, for example postgresql-17-pgvector. Upgrade an existing database with ALTER EXTENSION pg_morlet UPDATE after installing a newer package. Packages for PostgreSQL 19 follow 19.0 (releases).

On PostgreSQL 18 and later, pg_morlet can also be loaded from a directory outside the server, such as an image volume in a container, through extension_control_path and dynamic_library_path (from release 0.3.9-alpha.1, SQL 0.3.7, with a ready-made extension-only image ghcr.io/merkhet-io/pg_morlet:0.3.12-alpha.1-pg18; how).

Docker walkthrough with synthetic data

Install Git and Docker with Compose v2, then run:

sh
git clone https://github.com/merkhet-io/morletdb.git
cd morletdb
docker compose run --rm walkthrough

The first run builds the extension image and installs Python dependencies. It imports synthetic recordings, searches a known interval and reads matches back. Internet access is required. The synthetic data is a functionality demonstration, not evidence of diagnostic accuracy. The default walkthrough does not install pgvector.

To open SQL in the running demo database:

sh
docker compose exec db psql -U morlet -d morlet

The demo listens on localhost port 54329, database/user morlet, password morlet-demo-only. These are public local-demo credentials. docker compose down preserves the named volume; adding -v deletes its data.

Store, search, read back

The following example creates its own collection, independent of the Docker walkthrough. Run it once in a fresh database with pg_morlet installed. It stores two synthetic recordings as raw-only inputs, then builds envelopes explicitly. This is the envelope-search path; it does not create Morlet feature segments.

sql
SELECT morlet_create_stream_collection('public', 'getting_started');

SELECT morlet_start_recording(
  'public.getting_started', 'run-a', 4096, 'vibration', 'g',
  '{"asset":"spindle-1"}'::jsonb, 512, 256, 'zscore', 'none'
);
SELECT morlet_start_recording(
  'public.getting_started', 'run-b', 4096, 'vibration', 'g',
  '{"asset":"spindle-1"}'::jsonb, 512, 256, 'zscore', 'none'
);

SELECT morlet_append_chunk(
  'public.getting_started', 'run-a', 0,
  ARRAY(SELECT sin(i::float8 / 8) * (1 + 0.4 * sin(i::float8 / 300))
        FROM generate_series(0, 8191) AS i ORDER BY i)
);
SELECT morlet_append_chunk(
  'public.getting_started', 'run-b', 0,
  ARRAY(SELECT sin(i::float8 / 8) * (1 + 0.4 * sin(i::float8 / 300))
        FROM generate_series(0, 8191) AS i ORDER BY i)
);

SELECT morlet_enable_envelopes('public.getting_started');
SELECT morlet_build_envelopes('public.getting_started', 'run-a', 4096, 2048);
SELECT morlet_build_envelopes('public.getting_started', 'run-b', 4096, 2048);

SELECT * FROM morlet_search_envelope_interval(
  'public.getting_started', 'run-a', 0, 4096,
  '{"asset":"spindle-1"}'::jsonb, 10
);

SELECT * FROM morlet_read_interval(
  'public.getting_started', 'run-b', 0, 4096
);
SELECT * FROM morlet_stream_status('public.getting_started', 'run-a');
SELECT * FROM morlet_collection_status('public.getting_started');

There are fewer than ten admissible, non-overlapping intervals here; fewer than ten results is expected. Sample coordinates are zero-based, half-open: [0,4096) contains 4096 samples. Source exclusion removes overlapping intervals in the source recording, not every interval of that recording. Smaller distances indicate stronger similarity under the chosen scorer; distances are not probabilities.

MorletDB has two ways to find similar intervals. Exact search compares the query with every eligible stored window and returns the guaranteed nearest ones. Probe search is indexed vector search: pgvector's HNSW index proposes candidates for 13 time-shifted copies of the query, and those candidates are re-ranked with the exact distance. Returned distances are exact either way; probe search may miss a true match the index did not propose.

Call morlet_search_similar unless you have a reason not to. It uses probe search when probes are enabled for the window size and exact search otherwise; probe search itself switches to exact search when at most 5,000 windows are eligible, for example under a narrow metadata filter.

You needUseWhy
A sensible defaultmorlet_search_similarPicks the path for you
Complete, reproducible results: audits, reports, evaluations, ground truthExact search: morlet_search_envelope_interval, morlet_search_envelopesThe true top k, every time
Interactive search on up to about a million eligible windowsExact searchMeasured p95 with 8 workers: 138 ms at 235,000 windows, 221 ms at 505,000, 540 ms at 1.03 million (E7)
Narrow filters (one asset, one operating mode)Exact searchIt reads only eligible windows, so filters make it faster
Archives well beyond a million windows, or growing fastProbe search: morlet_enable_envelope_probes, then morlet_search_similarExact search time grows linearly with the archive; the index grows more slowly. 1.6–2.3× faster than exact search at p95 already at 0.23–1.03 million windows
Many searches at once (dashboards, several analysts or agents)Probe searchEach exact search keeps several CPU cores busy for its duration; probe searches are lighter
No pgvector, or no extra index to store and maintainExact searchNeeds nothing beyond the stored envelopes

Probe search found 99.0–99.8% of exact search's top 10 in E7 (recall@10 0.990–0.998). If missing about one match in a hundred is acceptable, for exploration or a quick “have we seen this before?”, it is the faster choice on large archives; if not, use exact search, with a metadata filter where you can.

Exact search runs as one parallel statement: raise max_parallel_workers_per_gather and, for large envelope tables, the table's parallel_workers storage parameter. It fails above max_candidates eligible windows rather than searching a subset. SET morlet.exact_screen = off disables the screen (SQL 0.3.3) and scores every window, with identical results.

On small machines, exact search uses its prepared query matrices only when they fit a quarter of the CPU’s last-level cache and otherwise scores windows directly, with bitwise the same distances (from 0.3.8-alpha.1). On a Raspberry Pi 3B+, an N1 query over 1,341 windows of 16,384 samples takes about 1.5 s instead of 15.1 s; desktops are unchanged. MORLET_SCORE_CACHE_BYTES in the server’s environment overrides the cache budget (edge latency ↗).

Install pgvector server files separately for the same PostgreSQL installation, then enable it in the same database. The commands below continue the example above.

sql
CREATE EXTENSION IF NOT EXISTS vector;
SELECT morlet_enable_envelope_probes('public.getting_started', 4096);
SELECT * FROM morlet_search_envelope_probes(
  'public.getting_started', 'run-a', 0, 4096,
  '{"asset":"spindle-1"}'::jsonb, 10
);

The function routes small eligible galleries (at most 5,000 windows by default) to exact search, so this tiny example exercises that fallback. On larger galleries it unions shifted HNSW candidates and re-ranks them with the envelope scorer: candidate selection is approximate even though returned distances are recomputed exactly on stored envelopes. It may return fewer than top_k after exclusions because the per-probe budget is fixed.

Envelopes are opt-in and are not automatically built by append. Call the bounded, resumable builder until the intended windows are covered. Once probes are enabled, the builder writes corresponding vector rows in the same transaction. To publish appended samples and new representations together, explicitly put append and build calls in one transaction. Under READ COMMITTED, the multi-statement probe function does not promise one atomic search snapshot.

Storage codecs

Every stored sample is compressed losslessly by Grizzbit, MorletDB’s chunk codec: a value read back has the same bits as the value written, and with morlet.verify_appends on (the default) every chunk is decoded again and compared before it is stored. You do not need to choose anything; the codec picks the smallest coding per chunk and records it in the chunk’s first byte.

CodecChunk formatStatusGood atMeasured
Grizzbit 12 and 3defaultSamples on a value grid (ADC counts, fixed decimals, calibrated waveforms); floats without a grid are stored as bit patterns or rawB4
Grizzbit 24reserved, never shippedWas meant for floats without a grid; failed its studyB5
Grizzbit 2.15opt-in, from 0.3.12-alpha.1Grid waveforms with structure: vibration, motor currents, EEG. In B6 the six waveforms were 12.6% smaller in total, motor currents 18–20%B6
Float32 codings6default for float32 samples, from 0.3.14-alpha.1Float32 machine data without a float64 grid: forces, controller torques and positions. In B8 the IU4 force and controller channels took 3.02 and 2.29 bytes per sample instead of 6.60 and 6.52B8

To use Grizzbit 2.1, set it for a database (or a session, or the whole server). Only chunks written afterwards use version 5; existing chunks keep their format, and a collection may mix both:

SQL
ALTER DATABASE plant SET morlet.chunk_format = 'grizzbit21';

How to check which format a recording uses, every option and the full results: Grizzbit ↗ and the format specification ↗.

Python SDK

The morletdb Python package (sdk/python, alpha, not yet on PyPI) lets you ingest your own waveform, read exact intervals, search for similar intervals and inspect the results in an afternoon. It needs only numpy and psycopg 3.2 or later, works with pg_morlet SQL 0.3.2 and later, including the release image (newer methods name the SQL version they need), and adds no database features of its own: every call is one of the SQL functions in the reference below. Releases attach wheels with the native decoder for compressed transfer (Linux x86-64 and arm64, macOS 11+ on arm64) and a pure wheel for other platforms (install a wheel); from a checkout, pip install ./sdk/python.

First run

sh
docker run -d --name morletdb -e POSTGRES_USER=morlet -e POSTGRES_PASSWORD=change-me \
  -e POSTGRES_DB=morlet -p 127.0.0.1:54329:5432 ghcr.io/merkhet-io/morletdb:0.3.14-alpha.1
docker exec morletdb psql -U morlet -d morlet -c "CREATE EXTENSION IF NOT EXISTS pg_morlet"
pip install "./sdk/python[plot]"          # from a checkout of the repository
DATABASE_URL=postgresql://morlet:change-me@127.0.0.1:54329/morlet python examples/python/quickstart.py

If the container from Fastest start is already running, skip the first two commands. The Docker walkthrough’s database also uses port 54329, so stop it first (docker compose down) or change the port.

The example generates two recordings of two minutes each at 8 kHz with impacts and electrical transients at known times, reads 0.5 s of an impact by time, searches from it and plots the query with its best matches. The four impacts rank first (distance about 0.01), ahead of the electrical transients (about 0.16–0.19).

Use

python
from datetime import datetime, timezone
import numpy as np
import morletdb

samples = np.load("pump7_accel.npy")                     # 1-D float or integer array
with morletdb.Client.connect("postgresql://...") as db:  # commits on a clean exit
    db.create_collection("public.signals")
    rec = db.ingest("public.signals", "pump-7/accel-x", samples, sample_rate_hz=25_600,
                    channel="accel-x", unit="m/s^2", metadata={"asset": "pump-7"},
                    start_time=datetime(2026, 3, 1, 8, 0, tzinfo=timezone.utc),
                    search_stride=512)                    # windows every 512 samples: see below
    iv = db.read("public.signals", "pump-7/accel-x", 40_960, 45_056)     # samples [40960, 45056)
    result = db.search("public.signals", "pump-7/accel-x", 40_960, metadata_filter={"asset": "pump-7"})
    for m in result.matches:
        print(m.rank, m.distance, m.ref)                 # recording + half-open sample range
        original = db.read_ref(m.ref).samples            # the stored samples, bit for bit
CallWhat it does
Client.connect(conninfo), Client(connection)An owned connection that commits on a clean exit and rolls back on an exception, or your own psycopg connection, which the SDK never commits or closes
create_collection(name)Creates a collection if it is missing and enables search envelopes and per-chunk summaries (SQL 0.3.3 and later; new collections have them by default from SQL 0.3.5)
ingest(collection, recording_id, samples, sample_rate_hz=, channel=, unit=, start_time=, metadata=, search_window=4096, search_stride=None)Stores a complete waveform losslessly as float64 (float and integer arrays widened exactly; NaN, infinities, 2-D input and values beyond ±10⁹ refused) and builds its search windows: one per search_window samples, starting every search_stride samples (default half a window)
append(collection, recording_id, start_sample, samples)Continues a recording at its next sample; identical replays are no-ops, different samples at stored offsets raise ConflictError. Search windows are not rebuilt
build_search_index(collection, recording_id, window=4096, stride=None)Builds a recording’s search windows, resumably (for example after append); returns the number built. A recording’s stride is fixed by its first build
read(collection, recording_id, start, end), read_ref(ref)The original samples of [start, end) as a float64 array; the end is exclusive. Reads of at least 32,768 samples use compressed transfer when the native decoder is installed and the server has SQL 0.3.5, verifying every chunk’s checksum, with bit for bit the same result
search(collection, recording_id, start, end=None, metadata_filter=, top_k=10)Similar stored intervals: distance, recording and sample range per match, and whether the search was exact or used probes. On SQL 0.3.3 also time_from, time_to, before_only (only history) and evidence=True (UTC times, statistics and an overview per match)
stats(...), recording(...), recordings(...), samples_for_time(...)Count, min, max, mean and RMS (SQL 0.3.3); recording information; UTC time range to sample range
overview(collection, recording_id, start, end, buckets=100)Count, min, max, mean and RMS in up to buckets consecutive parts of an interval, for example to draw an hour of data (SQL 0.3.3)
latest(collection, n=1, metadata_filter=None, max_recordings=1000)The newest data of every matching recording: its last n samples (1..32,768) with their statistics, or with n=0 the statistics of its newest stored chunk, and the end time (SQL 0.3.6)
enable_notifications(collection, channel=None, detail=True), disable_notifications(collection), notifications(collection, timeout=None, stop_after=None)Turn commit notifications on or off, and wait for them: one Notification per committed transaction that stored new chunks, with each recording’s new range. notifications needs an autocommit connection used only for listening; missed commits are not replayed, so catch up with latest (SQL 0.3.6)

What the SDK guarantees

Full contract, errors and limitations: Python SDK guide ↗ · example: quickstart.py ↗.

Connect an AI agent (MCP)

tools/mcp/morlet_mcp.py is a read-only Model Context Protocol server. It lets an agent such as Claude work through a recording archive the way an engineer would: see what is recorded, characterize an interval, find where similar behavior happened, read the original samples of every candidate and cite them.

ToolWhat it does
list_collectionsCollections the role can read, with recording counts, samples and search paths (window sizes, probe search)
coverageAn overview per station, sensor or asset: recordings, samples, first and last time, the recording schedule and the largest gaps; from SQL 0.3.6 also how much recorded time is searchable, not indexed yet, out of search by a memory level, represented, summarized, gone or missing
latestThe newest data of every matching recording: its last value, or up to 1,000 last values with their statistics, or the statistics of its newest chunk; its end time and how many seconds ago that was, and why, if the newest samples cannot be read (SQL 0.3.6)
list_recordingsRecordings with length and UTC start and end where known; shared rate, channel and unit given once; metadata on request; filter by metadata
trendHow level, impulsiveness and band power change over days or weeks, with candidate change points against an early baseline; optionally only inside context segments such as the cut phase, not the idle lead-in (segment_context), or ordered by a metadata key such as cut for recordings without a clock time (order_by)
locate_timeThe recordings containing a UTC time, and the sample offset of that time
describe_intervalLevel, impulsiveness (crest factor, kurtosis) and spectrum (dominant frequency, centroid, energy share and power per band) of an interval; your own bands in Hz, and a minimum frequency so slow background motion does not dominate
envelope_spectrumThe repetition frequencies of impacts in an interval (band-pass, amplitude envelope, spectrum): strongest lines, a noise-peak level, and the level at named frequencies such as bearing defect frequencies and their harmonics
compare_intervalsAn interval against the background around it (median over the surrounding minutes) or an explicit reference (a quiet stretch, normal operation, an earlier event): power ratio overall and per band in dB, and how the energy above the reference splits across bands; warns if an explicit reference contains an onset
find_onsetsCandidate onsets where short-term energy rises above the preceding background (STA/LTA, optional band-pass); candidates to inspect, not evaluated detections
search_from_timeThe intervals most similar to what happens at a UTC time: finds the onset near that time, frames the window and searches; the easiest way to search when you know when something happened
search_similarThe intervals most similar to a query interval, tolerant to small time shifts, with distances and UTC times; optionally also searches with the window moved a little and merges the results. From SQL 0.3.3, before_only searches only what happened before the query, and each hit carries its rms, min, max and a short min/max sketch. Every result carries a scope: the recordings and windows searched and, from SQL 0.3.6, the periods that were not searched and why, so a negative result can say what it did not cover
read_intervalOriginal samples of an interval, or a min/max envelope for long intervals, with statistics
decision_stateFor the segment (a cut, a cycle, a part) containing a sample: its context, how each value deviates from earlier segments with the same context (median, relative deviation, quantile), a summary score, and the nearest earlier cases with their labels; the compact state a fast decision model (System 1) decides from and an agent can cite. Flags a history too small to rely on; optionally adds the drift from the asset's own reference segments and labelled cases as examples, searched in the whole history and stated when there is none. Needs a collection with context
decisionsDecisions a fast model logged on recordings: answer, model, question, probability, threshold, time, the recordings and sample ranges and, on request, the exact state the model saw; the starting point for explaining a decision
labelsThe labels of a collection and what they mean: definition, unit and source from the label glossary, how many intervals and recordings carry each label, a few examples, and the labels nobody defined, so an agent does not guess a meaning from the name

Install the dependencies, then register the server with your client:

sh
python -m pip install -r tools/mcp/requirements.txt

# Claude Code
claude mcp add morletdb --env DATABASE_URL=postgresql://signals_reader@localhost/signals \
  -- python /path/to/morletdb/tools/mcp/morlet_mcp.py

For Claude Desktop, add the server to claude_desktop_config.json:

json
{"mcpServers": {"morletdb": {
  "command": "python",
  "args": ["/path/to/morletdb/tools/mcp/morlet_mcp.py"],
  "env": {"DATABASE_URL": "postgresql://signals_reader@localhost/signals"}}}}

Connect as a dedicated read-only role:

sql
CREATE ROLE signals_reader LOGIN PASSWORD '...';
GRANT USAGE ON SCHEMA public TO signals_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO signals_reader;  -- or only the collection's tables
-- tables created later (new collections) by the role that runs this:
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO signals_reader;

Safety. Every call runs in a read-only transaction with a statement timeout (MORLET_MCP_TIMEOUT_MS, default 30 s), as the role in DATABASE_URL, so privileges and row-level security decide what the agent sees. Errors such as an unknown collection or an interval of the wrong length come back to the agent as tool errors it can correct. Importing, labeling and building search structures stay with the operator.

A worked investigation. On the seismic development data of the technical report (four CX stations, 1–14 March 2025), an agent asked to find the M2.6 earthquake offshore Tarapacá on 14 March at station PB01 and to search for similar signals located the moment with locate_time, described it and searched. Three of the five closest intervals across four stations and two weeks were other cataloged earthquakes (M2.7–3.0); the other two had no catalog entry. For a weak signal, such as a distant earthquake only a few times above the background, compare_intervals against the background around it shows the frequency content of what is new rather than of the background. Where a search window starts changes which hits rank first, so when an agent knows when something happened, search_from_time finds the onset and frames the window itself. The full transcript, with tool calls and results, is in the MCP guide ↗.

Search intervals must have the stored window length (4,096 samples unless built otherwise; list_collections shows it). From SQL 0.3.2, search_similar uses probe search where it is enabled and exact search otherwise. No data yet? The agent demo ↗ sets up four days of public seismic recordings and the server in Docker, with a second, offline MCP server for station coordinates, an earthquake catalog snapshot and predicted arrival times. The machine demo ↗ sets up a bearing run to failure (NASA/IMS). Or import WAV, EDF, miniSEED or CSV files ↗ first. Check the server with DATABASE_URL=... python tools/mcp/smoke_stdio.py.

Run it unattended: the steward

tools/steward (from 0.3.8-alpha.1) keeps a MorletDB database healthy on an edge machine without a DBA. It runs the rebuildable upkeep (envelopes, chunk summaries, roll-ups, context values, a rolling morlet_verify pass every 7 days and ANALYZE of changed tables) in one budgeted loop, only below a load threshold (--load-per-cpu, default 0.7) and within the disk ladder, with a circuit breaker per job. It records an action row for every call and keeps one plain status, the view morlet_steward.status: one line per item with cause, consequence, action and who, for people and HMIs. Its role can build derived data and summaries and nothing that removes stored samples; its database test shows that DELETE, UPDATE of samples, TRUNCATE and DROP are refused.

sh
pip install -r tools/steward/requirements.txt
# once, as the database owner: schema, roles (morlet_steward, morlet_approver), grants
python tools/steward/steward.py init  --dsn "postgresql://owner@host/db"
python tools/steward/steward.py grant --dsn "postgresql://owner@host/db"   # again after new collections
psql -c "ALTER ROLE morlet_steward LOGIN PASSWORD '...'"
# the service
python tools/steward/steward.py run --dsn "postgresql://morlet_steward@host/db" --data-path /var/lib/postgresql/data
# for people and health checks
python tools/steward/steward.py status --dsn ...       # NORMAL / ATTENTION / ACTION REQUIRED, one action per item
python tools/steward/steward.py check --dsn ...        # exit 0, 1 or 2
python tools/steward/steward.py plans --dsn ...        # proposed plans and their state
python tools/steward/steward.py approve 12 --dsn ...   # another person (morlet_approver) approves
python tools/steward/steward.py apply 12 --dsn ...     # and applies it, in one transaction

Disk and the ingest hold. The steward watches free space as time remaining (attention at 14 days, pause at 3). At the reserve (by default the larger of 5% and 2 GiB; --reserve-fraction, --reserve-bytes) it sets the ingest hold with morlet_set_ingest_hold: appends that would store new samples then fail with SQLSTATE 53100 and the reason, before PostgreSQL itself runs out of disk, while identical replays still pass. When space is free again it releases the hold it set (morlet_release_ingest_hold); a hold set by a person stays until a person releases it. Writers can check beforehand with morlet_check_ingest_hold(), which raises the same error while a hold is set. --data-path is the directory whose filesystem holds the database; if the steward cannot read free space, it pauses all work and says so.

Plans. Anything that removes or changes stored samples, or updates the extension, is a plan. The steward proposes extension updates itself when the server has a newer pg_morlet; a person proposes memory-level changes from a policy dry run (propose-levels COLLECTION MOVES.json). Another person with morlet_approver approves it, and a person applies it with their own privileges, in one transaction. The rules are enforced in SQL (morlet_steward.decide, morlet_steward.apply): no self-approval, the payload tied to the approved hash, expiry after 24 hours, applied once. The steward’s role can propose and read plans, but cannot decide, apply or delete them.

Deploy. deploy/steward/Dockerfile is a sidecar image whose health check is healthy while the status is normal or attention. deploy/steward/compose.yaml runs a release image (MORLET_IMAGE) with data checksums and small-disk WAL settings, a one-shot init and grant, and the steward with the data volume mounted read-only. deploy/steward/morlet-steward.service is a systemd unit for package installs.

Tested. Filling a real 1.2 GB disk on a Raspberry Pi 3B+, the steward set the hold at 295 MB free, appends were refused with SQLSTATE 53100 and never with a PostgreSQL write error, freeing 200 MB resumed ingest 14.5 s later without a person, and every acknowledged sample read back bitwise (disk fill results ↗).

Not yet: dropping derived data under disk pressure by policy, automatic retention proposals, a multi-week soak on the Raspberry Pi, power-cut tests, and the status as a read-only MCP tool. Metrics: morlet-steward metrics serves Prometheus metrics on /metrics, read-only (#197). Design and rules: steward design ↗ · steward README ↗.

Main functions and query primitives

TaskEntry pointsMeaning
Create a collectionmorlet_create_stream_collectionCreates managed recording, chunk and segment tables
Declare a recordingmorlet_start_recording, morlet_ensure_recordingFixes rate, channel, units and extraction configuration
Append samplesmorlet_append_chunk, morlet_append_chunksGlobal sample offsets; identical retries are checked; gaps and conflicting retries fail. The plural form appends several chunks in one call
Append samples as bytesmorlet_append_chunk_raw, morlet_append_chunks_rawThe same append with samples as little-endian float64 bytes, without per-element parsing (SQL 0.3.3); the plural form takes a run of consecutive chunks in one call and stores exactly what one call per chunk stores (SQL 0.3.4), encoding the chunks on up to morlet.encode_threads threads (default up to 4; 1 = none) and, with morlet.verify_appends on (the default), decoding each chunk again to prove it bit for bit lossless; used by the importer
Read samplesmorlet_read_intervalReconstructs original samples, including across chunk boundaries. Reads of at least 8 chunks (this and the byte form) decode on up to morlet.decode_threads threads (1..64, default up to 4; 1 = the backend’s own thread), with identical results (SQL 0.3.5)
Read samples as bytesmorlet_read_interval_rawThe same samples as little-endian float64 bytes, up to 16,777,216 per call (SQL 0.3.3); map them with e.g. numpy.frombuffer(raw, "<f8")
Compressed transfermorlet_read_interval_encodedAn interval’s stored chunks unchanged, in one frame, under the same visibility, checkpoint and coverage checks as morlet_read_interval_raw, for clients that decode them (the Python SDK does, with its native decoder); chunks of a format version the client does not decode are sent as samples (SQL 0.3.5; design)
Chunk shippingmorlet_encoded_chunks, morlet_append_encoded_chunkA recording’s stored chunks as bytes, and appending a chunk another MorletDB encoded with its bytes kept, after decoding it and running the usual append checks (offset, identical replay a no-op, conflicting samples refused, ingest hold, checkpoint, summary). tools/ingest/morlet_ship.py ships recordings from edge to centre with them and compares the SHA-256 of all chunk bytes at the end (SQL 0.3.9; chunk shipping)
Interval statisticsmorlet_interval_statsCount, min, max, mean and RMS computed while decoding, without building a sample array (SQL 0.3.3); min and max exact. Each chunk’s part is summed with compensation in float64, and the parts are merged exactly and rounded once (SQL 0.3.6), so the result does not depend on how the interval is split. Uses chunk summaries and roll-ups where they exist
Interval overviewmorlet_interval_overviewThe same statistics in up to N consecutive buckets of an interval, for example a waveform overview of an hour of data (SQL 0.3.3)
Chunk summariesmorlet_enable_chunk_summaries, morlet_build_chunk_summariesEach chunk stores its count, min, max and sums, so statistics and overviews of long intervals decode only the chunks at the edges; results are identical. New collections have them by default since SQL 0.3.5; for older collections, these functions add and fill them (SQL 0.3.3)
Roll-upsmorlet_enable_rollups, morlet_build_rollupsExact roll-ups of chunk summaries in blocks of 220, 225 and 230 samples, so statistics and overviews of months or years read a few rows; results are bitwise identical. Kept through memory levels 2 and 3, removed with forgotten samples at level 4 (SQL 0.3.6)
Convert time and samplesmorlet_time_to_samples, morlet_sample_time_nsNanosecond time ranges to sample intervals and back, for a rate and a channel offset
Similar-interval searchmorlet_search_similarRecommended entry point: probe search when it is enabled for the window size, otherwise exact envelope search. Optional time_from, time_to and before_only (only windows that end before the query starts) filter by recording time (SQL 0.3.3)
Search with evidencemorlet_search_similar_evidenceThe same matches with UTC times, statistics and an overview of each matched interval inline, so a match can be judged without reading it (SQL 0.3.3)
Search leaving out the same partmorlet_search_similar_excludingThe same matches with evidence, with exclude: 'window' (the default, identical results), 'occurrence' (every context segment of the query’s occurrence, for example the same part, in any recording), 'recording' or 'none'. On cyclic production this keeps the next cycles of the query’s own part out of the answer; excluded windows do not count towards max_candidates. Experimental (SQL 0.3.11)
Feature searchmorlet_search_streams, morlet_search_intervalExact search over compatible Morlet/statistical feature segments, not bounded-lag envelope scoring
Build envelopesmorlet_enable_envelopes, morlet_build_envelopesOpt-in materialized search representation
Exact envelope searchmorlet_search_envelopes, morlet_search_envelope_intervalInput samples or stored interval; bounded-lag correlation. From SQL 0.3.3 a float32 screen with a rigorous error bound skips windows that cannot be among the nearest; results are identical (SET morlet.exact_screen = off scores every window)
Indexed envelope searchmorlet_enable_envelope_probes, morlet_search_envelope_probes, morlet_envelope_probeOptional pgvector path with exact fallback for small galleries; morlet_envelope_probe is the shifted query envelope whose inner product with a stored envelope gives the distance’s lag term
Inspect progressmorlet_stream_status, morlet_collection_statusRecording progress, representation coverage and probe backlog
Inspect storagemorlet_recording_storage, morlet_collection_storageLogical payload and physical storage accounting are distinct
Episodic memorymorlet_enable_memory, morlet_add_episodes, morlet_start_memory_run, morlet_set_level, morlet_finish_memory_run, morlet_memory_statusEpisodes with context and importance, moved down a compaction ladder (not searchable, represented, summarized, gone) with a dry run and a ledger of every move (SQL 0.3.5; design)
Newest datamorlet_latestPer recording, the last n samples with their statistics, or the newest chunk’s statistics, and the end time; a fixed number of index lookups per recording; unreadable newest samples are a note (SQL 0.3.6)
What search can findmorlet_coveragePer recording, the sample ranges that are searchable, not indexed yet, out of search by a memory level, compacted, gone or missing, so a negative result can say what was not searched (SQL 0.3.6)
Verify stored datamorlet_verifyIntegrity scrub: every chunk decoded and checked, summaries compared, coverage up to the checkpoint, and from SQL 0.3.6 members of synchronous groups whose end or chunk boundaries differ; reports problems without changing data, in resumable batches (SQL 0.3.5)
Commit notificationsmorlet_enable_notifications(collection, channel, detail), morlet_disable_notifications, morlet_notification_channelOne pg_notify per committed transaction that stored new chunks, with the stored sample ranges per recording; rolled-back transactions, savepoints and identical replays send nothing. The channel defaults to morlet_ and the table name; detail => false sends counts only, and row security requires it; morlet_notification_channel returns the channel and detail setting, or no row when notifications are off (SQL 0.3.6; operations)
Ingest holdmorlet_set_ingest_hold(reason), morlet_release_ingest_hold, morlet_check_ingest_holdWhile a hold is set, appends that would store new samples fail with SQLSTATE 53100 (disk_full) and the reason; identical replays still pass. The steward sets and releases it at its disk reserve; a hold set by a person stays until a person releases it. COPY or INSERT straight into chunk tables bypasses it (SQL 0.3.6)
TOAST compressionmorlet_pin_toast_compression(collection)Pins one collection’s sample and derived columns to pglz and returns the number of columns changed, for example after restoring a dump made with --no-toast-compression; catalog only. The update to SQL 0.3.6 does this for every existing collection (compatibility)
Enable companion-table RLSmorlet_enable_row_security, morlet_row_security_enabledComplements recording-table policies; ordinary grants still apply
Operating contextmorlet_enable_context, morlet_add_context, morlet_revise_context, morlet_index_contextTimed context events (program, block, tool, part, …); batches are idempotent; revisions and retractions keep the history. Context enabled from SQL 0.3.9 on has an index on the batch id; morlet_index_context adds it to older collections (once, idempotent), so an append no longer scans the context table
Context segments and valuesmorlet_build_context_segments, morlet_build_context_valuesCut recordings into segments by context keys and compute versioned per-segment values (duration, level, spread, peak and shape)
Compare with historymorlet_compare_to_history, morlet_compare_summary, morlet_history_neighborsDeviation of a segment from earlier segments in the same context, a one-row summary score, and the closest past segments with their labels; before_only restricts history to earlier time. History counts only segments built with the query segment’s segmentation spec (keys, occurrence keys, min_samples, trim_samples) and never a segment that overlaps the query’s own samples (SQL 0.3.6); history built with other settings gives insufficient_history until it is rebuilt
Value rules and spreadsmorlet_value_rules, morlet_set_value_rules, morlet_value_spreads, morlet_value_floors, morlet_simple_value_floors, morlet_quantised_stepHow a collection compares segment values. Under simple-v2 (the default for context enabled from SQL 0.3.9; older collections keep simple-v1) a history on a converter grid is recognised (morlet_quantised_step), and a median within two steps of zero counts as zero; values that are not quantised compare as before, and stored values never change. morlet_value_floors and morlet_simple_value_floors give the medians treated as zero; morlet_value_spreads gives the pooled relative spread per value used for calibrated deviations
Track a levelmorlet_level_trackKalman level track of a segment value under the same operating conditions: sustained change versus short-lived variation
Labelsmorlet_add_label, morlet_retract_labelLabels on sample intervals, used as data, for example to exclude known-bad history
Label glossarymorlet_define_label(collection, label, definition, unit, source), morlet_label_definitionsWhat a label means, recorded once per collection and listed with its unit and source, so a person or an agent does not have to guess, for example that not_sharp means flank wear above 0.2 mm (SQL 0.3.9)
Acquisitionsmorlet_enable_acquisitions, morlet_create_acquisition, morlet_add_acquisition_channel, morlet_add_acquisition_context, morlet_build_acquisition_segments, morlet_compare_acquisition, morlet_read_acquisition, morlet_read_acquisition_rawGroups of channels with their own rates and clock offsets: read one time interval from every channel (the _raw variant returns little-endian float64 bytes, SQL 0.3.3), and compare an occurrence across channels
Synchronous channel groupsmorlet_enable_sync, morlet_declare_sync, morlet_append_acquisition_raw, morlet_read_sync_raw, morlet_acquisition_cross_statsAn acquisition sampled at the same instants (one DAQ, one rate) declared synchronous: its members are appended together in one transaction, read by sample index (failing closed), and summarized exactly across channels, for example mean(v·i) as real power, bitwise the same for any chunking. Single-member appends, partial memory moves and new channels are refused (SQL 0.3.6; context workflow)
Array primitivesmorlet_rms, morlet_normalize, morlet_distance, morlet_features, morlet_envelope_distance, morlet_simple_values, morlet_simple_value_namesRMS, normalization, aligned sample distance, features, envelope scoring and the per-segment values are different operations
Versionsmorlet_version, morlet_extractor_version, morlet_envelope_version, morlet_simple_values_versionExtension, feature extractor, envelope and segment-value versions, to record with derived results

Filters use JSONB containment, for example '{"asset":"spindle-1"}'::jsonb. Eligibility also requires compatible rate, channel, unit and representation/window settings. Exact searches have explicit candidate budgets and fail when exceeded rather than silently searching a prefix. Returned intervals are ordered by distance, recording ID and start sample, with source exclusion and greedy overlap suppression enabled by default on stored-interval searches.

Do not directly edit managed companion tables. Versioned extractors, original samples and representations have different roles. This guide uses the active continuous-recording API, not the legacy bounded-row APIs. Functions not listed here (relation lookups, the chunk codec, checkpoints and envelope internals) are internal helpers and may change between versions. Context, comparison and acquisition workflows are described in the context workflow guide ↗.

Supported versions

ComponentCurrent scope
PostgreSQL 17Reference version; functional CI with and without pgvector; upgrade tests; release images and Debian packages
PostgreSQL 18Supported for fresh installations; functional CI with and without pgvector; Debian packages from 0.3.5-alpha.1; no release image or measured performance
PostgreSQL 19Supported at its release candidate (19rc1) for fresh source installations; functional CI with and without pgvector; no release image or packages yet (they follow 19.0). PostgreSQL 19 changes the default TOAST compression from pglz to lz4; pg_morlet pins its sample and envelope columns to pglz, so a collection stores the same bytes as on 17 and 18 (compatibility, measurement)
Rust / pgrxRepository-pinned Rust 1.99.0; pgrx and cargo-pgrx 0.19.3
Release imagePostgreSQL 17; Linux amd64; Linux arm64 from 0.3.3-alpha.1 (built and trialed natively in the release workflow)
Debian packagespostgresql-17-pg-morlet and postgresql-18-pg-morlet for amd64 and arm64, built against the PostgreSQL project’s packages (apt.postgresql.org) on Debian bookworm and trialed there, from 0.3.5-alpha.1 (install)
pgvectorOptional. Probe search requires 0.8 or later (0.8.1 or later on PostgreSQL 18); CI exercises 0.8.0 (PostgreSQL 17) and 0.8.7 (18 and 19). Use 0.8.4 or later in production: pgvector 0.8.2–0.8.4 fixed HNSW build and vacuum bugs

This describes the current source and CI configuration, not a promise that every CI run passes. The paper benchmarks use PostgreSQL 17; compatibility tests and performance measurements are separate.

Build a development installation

Install Rust through rustup. On Debian/Ubuntu, install the build prerequisites below. Run pgrx as a non-root OS user; only the apt command needs administrator privileges.

sh
sudo apt-get install build-essential bison flex libreadline-dev zlib1g-dev libclang-dev clang pkg-config libssl-dev
git clone https://github.com/merkhet-io/morletdb.git
cd morletdb
rustup show
cargo install cargo-pgrx --version 0.19.3 --locked
cargo pgrx init --pg17 download
cd extensions/pg_morlet
cargo pgrx run pg17

This builds a user-owned PostgreSQL 17 installation and opens psql. In the selected database:

sql
CREATE EXTENSION IF NOT EXISTS pg_morlet;
SELECT morlet_version();

CREATE EXTENSION enables already-installed extension files; it does not download or compile the extension. No shared_preload_libraries entry is required. Hosting providers must permit installation of this extension. For packaging against an existing PostgreSQL 17 installation, use cargo pgrx package --pg-config /absolute/path/to/pg_config; installation into server directories requires suitable filesystem privileges.

Other PostgreSQL versions build the same way, one Cargo feature at a time, in extensions/pg_morlet:

sh
# PostgreSQL 18
cargo pgrx init --pg18 download
cargo pgrx run pg18 --no-default-features --features pg18
# PostgreSQL 19 (release candidate 19rc1, built from the PostgreSQL source tarball)
cargo pgrx init --pg19 /path/to/19rc1/bin/pg_config
cargo pgrx run pg19 --no-default-features --features pg19

Register a 19rc1 build by its pg_config, since cargo pgrx init --pg19 download fetches 19beta4. Probe search on 18 needs pgvector 0.8.1 or later (CI uses 0.8.7). PostgreSQL 16 and older are not supported. See the compatibility matrix.

Further reading

Function names and signatures on this page follow extensions/pg_morlet/sql/streams.sql of the release; installation commands follow the Cargo features, the release workflow and the release notes. CI runs the Python quickstart against PostgreSQL 17 on every change; the SQL snippets on this page are not run by CI.