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.
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:
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:
git clone https://github.com/merkhet-io/morletdb.git
cd morletdb
docker compose run --rm walkthroughThe 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:
docker compose exec db psql -U morlet -d morletThe 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.
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.
Which search to use
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 need | Use | Why |
|---|---|---|
| A sensible default | morlet_search_similar | Picks the path for you |
| Complete, reproducible results: audits, reports, evaluations, ground truth | Exact search: morlet_search_envelope_interval, morlet_search_envelopes | The true top k, every time |
| Interactive search on up to about a million eligible windows | Exact search | Measured 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 search | It reads only eligible windows, so filters make it faster |
| Archives well beyond a million windows, or growing fast | Probe search: morlet_enable_envelope_probes, then morlet_search_similar | Exact 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 search | Each exact search keeps several CPU cores busy for its duration; probe searches are lighter |
| No pgvector, or no extra index to store and maintain | Exact search | Needs 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 ↗).
Optional indexed probe search
Install pgvector server files separately for the same PostgreSQL installation, then enable it in the same database. The commands below continue the example above.
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.
| Codec | Chunk format | Status | Good at | Measured |
|---|---|---|---|---|
| Grizzbit 1 | 2 and 3 | default | Samples on a value grid (ADC counts, fixed decimals, calibrated waveforms); floats without a grid are stored as bit patterns or raw | B4 |
| Grizzbit 2 | 4 | reserved, never shipped | Was meant for floats without a grid; failed its study | B5 |
| Grizzbit 2.1 | 5 | opt-in, from 0.3.12-alpha.1 | Grid waveforms with structure: vibration, motor currents, EEG. In B6 the six waveforms were 12.6% smaller in total, motor currents 18–20% | B6 |
| Float32 codings | 6 | default for float32 samples, from 0.3.14-alpha.1 | Float32 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.52 | B8 |
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:
ALTER DATABASE plant SET morlet.chunk_format = 'grizzbit21';- Worth it for waveforms on a value grid. It gains little on plain counts (ECG, seismic) and nothing on floats without a grid, and it encodes at about half to three quarters of Grizzbit 1’s speed.
- Compatibility: readers older than the release that ships it cannot read version-5 chunks; compressed transfer gives such clients plain samples. Embedded-edition files always use Grizzbit 1.
- Turning it off:
ALTER DATABASE plant RESET morlet.chunk_format;Chunks already written stay readable.
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
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.pyIf 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
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| Call | What 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
- Lossless samples. Values are stored as float64 and read back bit for bit; the SDK never resamples, normalizes or lowers precision. Search uses a derived envelope representation only to find candidates; every result is read from the original samples.
- Explicit coordinates. Intervals are half-open sample ranges
[start, end).start_timeis the UTC time of sample 0, a timezone-awaredatetimeor integer nanoseconds (naive datetimes are refused); sample n is atstart + n / sample_rate_hz. - Distance, not probability. The distance is one minus the best correlation of the two envelopes over small time shifts: about 0 for the same shape, smaller is more similar, comparable only within one window size.
- Where search windows start. Stored windows of 4,096 samples start every
search_stridesamples (default 2,048), and search tolerates shifts of about ±300 samples. Events between window starts can be missed with the default;search_stride=512covers every offset at four times the envelope storage. In the example, the default found one of four impacts and 512 found all four. - Your transactions. Methods never commit on a connection you pass in. Identical re-ingests are no-ops; conflicting ones raise and leave the stored samples unchanged.
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.
| Tool | What it does |
|---|---|
list_collections | Collections the role can read, with recording counts, samples and search paths (window sizes, probe search) |
coverage | An 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 |
latest | The 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_recordings | Recordings with length and UTC start and end where known; shared rate, channel and unit given once; metadata on request; filter by metadata |
trend | How 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_time | The recordings containing a UTC time, and the sample offset of that time |
describe_interval | Level, 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_spectrum | The 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_intervals | An 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_onsets | Candidate onsets where short-term energy rises above the preceding background (STA/LTA, optional band-pass); candidates to inspect, not evaluated detections |
search_from_time | The 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_similar | The 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_interval | Original samples of an interval, or a min/max envelope for long intervals, with statistics |
decision_state | For 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 |
decisions | Decisions 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 |
labels | The 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:
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.pyFor Claude Desktop, add the server to claude_desktop_config.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:
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.
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 transactionDisk 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
| Task | Entry points | Meaning |
|---|---|---|
| Create a collection | morlet_create_stream_collection | Creates managed recording, chunk and segment tables |
| Declare a recording | morlet_start_recording, morlet_ensure_recording | Fixes rate, channel, units and extraction configuration |
| Append samples | morlet_append_chunk, morlet_append_chunks | Global sample offsets; identical retries are checked; gaps and conflicting retries fail. The plural form appends several chunks in one call |
| Append samples as bytes | morlet_append_chunk_raw, morlet_append_chunks_raw | The 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 samples | morlet_read_interval | Reconstructs 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 bytes | morlet_read_interval_raw | The 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 transfer | morlet_read_interval_encoded | An 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 shipping | morlet_encoded_chunks, morlet_append_encoded_chunk | A 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 statistics | morlet_interval_stats | Count, 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 overview | morlet_interval_overview | The 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 summaries | morlet_enable_chunk_summaries, morlet_build_chunk_summaries | Each 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-ups | morlet_enable_rollups, morlet_build_rollups | Exact 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 samples | morlet_time_to_samples, morlet_sample_time_ns | Nanosecond time ranges to sample intervals and back, for a rate and a channel offset |
| Similar-interval search | morlet_search_similar | Recommended 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 evidence | morlet_search_similar_evidence | The 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 part | morlet_search_similar_excluding | The 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 search | morlet_search_streams, morlet_search_interval | Exact search over compatible Morlet/statistical feature segments, not bounded-lag envelope scoring |
| Build envelopes | morlet_enable_envelopes, morlet_build_envelopes | Opt-in materialized search representation |
| Exact envelope search | morlet_search_envelopes, morlet_search_envelope_interval | Input 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 search | morlet_enable_envelope_probes, morlet_search_envelope_probes, morlet_envelope_probe | Optional 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 progress | morlet_stream_status, morlet_collection_status | Recording progress, representation coverage and probe backlog |
| Inspect storage | morlet_recording_storage, morlet_collection_storage | Logical payload and physical storage accounting are distinct |
| Episodic memory | morlet_enable_memory, morlet_add_episodes, morlet_start_memory_run, morlet_set_level, morlet_finish_memory_run, morlet_memory_status | Episodes 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 data | morlet_latest | Per 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 find | morlet_coverage | Per 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 data | morlet_verify | Integrity 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 notifications | morlet_enable_notifications(collection, channel, detail), morlet_disable_notifications, morlet_notification_channel | One 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 hold | morlet_set_ingest_hold(reason), morlet_release_ingest_hold, morlet_check_ingest_hold | While 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 compression | morlet_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 RLS | morlet_enable_row_security, morlet_row_security_enabled | Complements recording-table policies; ordinary grants still apply |
| Operating context | morlet_enable_context, morlet_add_context, morlet_revise_context, morlet_index_context | Timed 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 values | morlet_build_context_segments, morlet_build_context_values | Cut recordings into segments by context keys and compute versioned per-segment values (duration, level, spread, peak and shape) |
| Compare with history | morlet_compare_to_history, morlet_compare_summary, morlet_history_neighbors | Deviation 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 spreads | morlet_value_rules, morlet_set_value_rules, morlet_value_spreads, morlet_value_floors, morlet_simple_value_floors, morlet_quantised_step | How 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 level | morlet_level_track | Kalman level track of a segment value under the same operating conditions: sustained change versus short-lived variation |
| Labels | morlet_add_label, morlet_retract_label | Labels on sample intervals, used as data, for example to exclude known-bad history |
| Label glossary | morlet_define_label(collection, label, definition, unit, source), morlet_label_definitions | What 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) |
| Acquisitions | morlet_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_raw | Groups 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 groups | morlet_enable_sync, morlet_declare_sync, morlet_append_acquisition_raw, morlet_read_sync_raw, morlet_acquisition_cross_stats | An 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 primitives | morlet_rms, morlet_normalize, morlet_distance, morlet_features, morlet_envelope_distance, morlet_simple_values, morlet_simple_value_names | RMS, normalization, aligned sample distance, features, envelope scoring and the per-segment values are different operations |
| Versions | morlet_version, morlet_extractor_version, morlet_envelope_version, morlet_simple_values_version | Extension, 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
| Component | Current scope |
|---|---|
| PostgreSQL 17 | Reference version; functional CI with and without pgvector; upgrade tests; release images and Debian packages |
| PostgreSQL 18 | Supported for fresh installations; functional CI with and without pgvector; Debian packages from 0.3.5-alpha.1; no release image or measured performance |
| PostgreSQL 19 | Supported 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 / pgrx | Repository-pinned Rust 1.99.0; pgrx and cargo-pgrx 0.19.3 |
| Release image | PostgreSQL 17; Linux amd64; Linux arm64 from 0.3.3-alpha.1 (built and trialed natively in the release workflow) |
| Debian packages | postgresql-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) |
| pgvector | Optional. 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.
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 pg17This builds a user-owned PostgreSQL 17 installation and opens psql. In the selected database:
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:
# 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 pg19Register 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
- AI agents over MCP ↗
- Python SDK guide ↗
- Import recordings from files ↗
- Detailed search contract ↗
- Durable ingestion and envelope lifecycle ↗
- Access control ↗
- Context and compare-to-history ↗
- Operations and recovery ↗
- Operator runbooks ↗
- Steward: usage ↗ and design ↗
- Episodic memory and graded compaction ↗
- PostgreSQL compatibility ↗
- Verification of the chunk codec ↗
- Versioned releases ↗
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.