Repository navigation
v1.5.0
Three new public primitives, plus a real fix to cursor teardown. All three copy-or-delete helpers exist to remove hand-rolled boilerplate at corm_get() / corm_next() call sites, where each call site had to re-derive the destination size — and where the size was routinely derived wrongly (see the measured-type trap below).
corm_get_copy()copies a value out by its real length (corm.h:364):void corm_get_copy(uint32_t hd, const void *const key, void *dst)writes the stored value todstand returns nothing. The destination must be at least as large as the value, and the size is not returned and no truncation is performed — a caller that wants truncation keeps usingcorm_get()withstrlcpy(). Two failure modes are turned into hard errors rather than silent corruption: a missCBUG()s, because a caller reaching for a copy of a value that is not there has a bug, not a runtime condition (callers that legitimately probe for presence usecorm_get()and test forNULL, orcorm_count()); and field access on a record-aware map — the"id:field"composite keys — is rejected withCBUG(), becausecorm_get()resolves those to a pointer inside the stored struct, whose length is the field's and not the map's value type's, so sizing a copy from the value type would overrun both the field and the caller's buffer.corm_next_copy()advances a cursor and copies both sides (corm.h:651):int corm_next_copy(void *key, void *value, uint32_t cur_id)writes the key and/or value to the supplied buffers and returns 1 while the iteration has entries, 0 when done.NULLskips that side. As withcorm_next(), the cursor is released by corm at end-of-iteration and acorm_fin()after the loop that returned 0 is a no-op. The cursor's map handle is read from the cursor beforecorm_next()runs, because that call may release the cursor and recycle its id.corm_del_value()deletes one specific value from a duplicate chain (corm.h:438):int corm_del_value(uint32_t hd, const void *const key, const void *const value)removes the first chain entry equal tovalueand returns 1, or returns 0 if nothing matched. Iteration stops at the first match: the entry is unlinked and freed before returning, so the cursor is never advanced again. On a non-multivalue map the chain is a single slot which is not separately addressable, sovalueis ignored and this behaves exactly likecorm_del(). Values are compared by content, so a measured or variable-width type matches on its bytes rather than on the identity of the caller's buffer. Note this is deliberately not a remove-all:corm_del_all()remains the remove-everything-under-this-key operation.- The measured-type trap these three share (
corm_len):corm_type_len()returns 0 for any type registered withcorm_mreg(), which is howCM_STRis registered — so a copy sized withcorm_type_len()copies zero bytes for a string, and does so silently. The new primitives size themselves withcorm_len(type, ptr), which measures the actual value. This is the class of bug the primitives exist to absorb: four independent hand-rolled copy helpers in the downstreamaxil-ndport had it, two of which shipped a silently truncated string. - Cursor release was not idempotent (
corm_cur_release, internal —libcorm.c:2051):corm_fin()calledidm_del()on every live cursor, butidm_del()pushes the id onto its free list unconditionally, so calling it twice for the same cursor pushed a live id onto the free list a second time. A subsequentcorm_iter()could then hand that same id to a second live cursor, and the two would share one cursor slot. Reachable today from any caller that does the idiomaticwhile (corm_next(…)); corm_fin(cur);, because corm already released the cursor when the loop returned 0 — a double-free of the cursor id. Release now clears the handle field before freeing, so the second call is a no-op andcorm_fin()is safe to call after the loop. - Regression coverage:
test_corm_get_copyandtest_corm_next_copyintest_extended.ccover a hit for a fixed-size type,CM_U32,CM_PTRandCM_STR(destination achar[], asserting the full string is copied), plus a miss that must abort, run in a child process — the abort is observed as the child's signal/exit status, and since mingw has nofork()/sys/wait.hthat one check is#ifndef _WIN32-skipped there (the Windows packaging job only compiles the suite);test_del_value_u32,test_del_value_isolated,test_del_value_str,test_del_value_non_multivalueandtest_get_copy_measuredintest_multivalue.ccover the delete and the measured-copy length. TheCM_STRdelete test asserts that two distinct buffers with identical bytes are not compared by pointer identity. - On-disk format and all pre-existing public API signatures are unchanged; databases written by 1.4.x and earlier load correctly, and no behaviour visible through the old API changed.
corm.pcstill readsVersion: 0.6.0and is deliberately left alone — it has tracked a stale version string for several releases, and correcting it belongs to a packaging change rather than a library release.
1.4.1
- Multi-db file load dropped databases — and the exit-save then made it permanent (
corm_load_file/_corm_load): loading walked the mapping by counting the file's registered handles, one_corm_loadper id, each using the iterated handle as the load target. That only pairs correctly when the id list's order matches the on-disk block order, which it does not (blocks are emitted inids_iterorder, handles are registered as they are opened). Any database whose block the walk failed to reach came back as an empty map — and sincecorm_save()runs at process exit, a reader re-wrote the file from the emptiness, permanently truncating the block it had missed. Repro on the pre-1.4.1 library: a 3-db file opened 2 of 3 dbs empty; a 2-db file opened one empty and shrank 52 → 42 bytes on read. This is the "silently dropped" class that 1.4.0's F4 entry hit on the libstoma sidecar.corm_load_filenow takes the requestinghdfromcorm_openand walks the mapping by each block's on-disksizeprefix, so any block whoselidmatches is loaded into the handle that asked for it, whatever order the file is in. - Off-by-8 block skip (
_corm_load): the dbid-mismatch branch returnedmm + size - mm_startaftermmhad already advanced past the 8-byte[dbid][size]block header, so the walk drifted 8 bytes too far per skipped block and read the next block'slid/sizefrom mid-payload bytes. The resulting garbage length can walk past the end of the mapping (one 3-db repro segfaulted here). The skip now honors the on-disksizeexactly. - Bounds-checked block walk (
corm_load_file): every block'ssizeprefix is validated (>= 8andmm + block <= end) and a non-matchinglidis skipped without entering_corm_load, so a truncated, corrupt, or foreign file stops at the mapping end instead of running off it. corm_close+ save no longer zeroes the file (corm_save_file): thefile->size == 0early return ran afteropen()+ftruncate(), so once every handle for a file was closed, the nextcorm_save()truncated the file to 0 bytes and threw away its contents. The early return now precedes both, keeping the data on disk (verified 0 → 20 bytes for a closed one-key map). Side effect: a path that never held data no longer gets a 0-byte file created for it.- Regression coverage:
test_persist(wired intomake testviatest.sh) drives a three-phase cross-process round trip — write, read back in a fresh process in a different order than the writer used, then save-after-close. The read phase is what the pre-1.4.1 loader fails: it drops a database and segfaults; the close phase catches the 0-byte truncation. - On-disk format and all public API signatures are unchanged; files written by 1.4.0 and earlier load correctly.
1.4.0
- Renamed
libqmap→libcorm: theqmap_*API and headers are nowcorm_*(include/ttypt/corm.h,qmap.pc→corm.pc); the full test/example suite was migrated to the new name. - The Recall Kernel (
include/ttypt/rec.h,src/rec.c) — a domain-free "filter by axis → join → rank" loop over uniform 32-bit refs:rec_set_t: arena-backed candidate sets (rec_set_push, thenrec_set_seal= sort + dedup;rec_set_intersect/_union/_subtractare sorted merge-joins;rec_set_count/rec_set_at; drain any corm handle withrec_set_fill_corm_iter).rec_rank_t: bounded top-k min-heap with a score threshold —rec_rank_new(top_k, min_score)/rec_rank_push(O(log k))/rec_rank_sorted(best-first, ties by ascending ref); scoring viarec_score_fn.- Refs are
rec_ref_t(uint32_t), are never interpreted by the kernel, and never an axis's internal key.
- Exact vs approximate fills: an approximate axis (ANN/semantic top-m) must declare itself —
rec_set_set_approx/rec_set_approx/rec_set_recall_bound— and joins propagate it (intersect/union → approximate when either operand is, bound = min; subtract keeps the left operand's flag), so intersecting with an approximate set bounds final recall. rec_queryengine + axis plugin registry — registry-driven query composition running wholly inside libcorm with zero axis dependencies:rec_axis_register/rec_axis_set_ctx,rec_axis_t(name/fill/rank/ctx/decode), andrec_query_runwithrec_join_t(AND/OR/NOT),top_k/min_score, and an optionalconsumer_scorecallback fedrec_axis_score_t[]per surviving ref.dlopen-loading of axis plugins happens on the consumer/CLI side only.- Rank composition: the first rank-capable axis in query order wins (preorder in
corm_expr_eval, kernel fallback inrec_query_run); labeled instanceslabel:axisrank by the per-ref MAX;--rank=A(or scoped--rank@A) pins the ranker to one labeled instance;--scoremulti-ranker combining deferred. - New kernel features: proximity / approximate ranking, the
QM_RANGE_GErange-eval flag, andcorm_get_ktypeto ask a handle's key type. - corm CLI composition (
src/corm.c):-Xexpression trees over a@-roster of axes (dlopen by name from$CORM_AXIS_PATH),-p "<whole record string>"fan-out to every bound axis,--paramsper-axis retrieval grammar,-g/-m/-cread-back rendering, and per-axis CLI long-options viarec_axis_cli_options/rec_axis_config_arg(scoped--name@label, precedence leaf spec >@label>@axis> unscoped > env). Typed param parsing rides therec_cli_*helpers (rec_cli_int/_uint/_float/_size/_str_dup/_str_set+_bvariants) backed byrec_spec_scan'skey=valuespec grammar (quoted strings included). - Axis plugin conventions (optional, CLI-specific, never declared/exported by libcorm):
rec_axis_open(spec)(opaque spec → store ctx), and the Phase 2A store halfrec_axis_store/rec_axis_store_typed/rec_axis_unstore/rec_axis_readback— the consumer passes(ref, whole-string value)blindly; each axis parses in its own grammar;unstoreis idempotent (delete cost ∝ entries the ref owns, never O(store)). - Test & bench suite:
rec_test(sets/joins/ranks + 1C exactness matrix),bench_rec(kernel merge-joins vs hand-rolled parity),rec_axis_test/rec_axis_store_test/rec_cli_test/rec_axis_bench, a mock two-axis plugin (librec_axis_mock.c) proving the dlopen shape end-to-end, plus the newtest-cli.sh,test-fanout.sh,test-mm.sh,test-real.sh,test-roster.sh, andtest-shortcircuit.shshells (with in-repo plugin-stub starterslibrec_axis_{fold,plain,probe,stub,zed}.c). - Rename + axis-boilerplate passes above all ride on
rec_ref_t = uint32_t; refs and axis params are CLI parameters everywhere. -Xgrammar pinned: verified against the built 2B-3 parser (mm-planPHASE-2-CLI.mdD10) — quote + whole-stringVALUEsemantics, unaryNOTwith theuse EXCEPThint, uppercase reserved keywords. Two behaviors are now pinned by gate rows:EXCEPTchains left-to-right (except-chain,test-cli.sh), and the first rank-capable leaf in preorder wins even with two rankers in one expression (stoma-over-sepal,test-real.sh).- F4 — duplicate-open fix:
corm_opennow aliases the live handle when the same (file, map) is opened twice with the same key/value shape (record type, key/value types, table mask), while still registered in the file's ids. Previously the second handle orphaned the first and its as-of-open copy won the exit-save — libstoma's sidecar-scan mirror-open of an already-held primary silently dropped-pseeds (pre-existing roster) and-dforgets. Real-file TDD viatest-mm.sh(RED-A/RED-B) → green. - 2B-5 mm dialect (mm-plan
2B-5-IMPLEMENTATION.md): pi-mm recipes as documented CLI invocations — explicit-ref store (-p REF:"<DATE>:<TEXT>"), bounded-window joint ∧ stoma composed search, idempotent roster-backed forget, and the enumerate+forget reset loop (no single-flag reset exists; the-1empty sentinel is skipped).test-mm.shgate wired intomake test. - CLI tuning:
QDBE_MASKshrinks32767 → 4095(D11 — an initial hint, auto-grow); newCORM_MASKenv override (validated2^n-1) shared by the CLI opens, the test plugins, and libstoma's sidecar rebuild so co-opened files match.gen_put/gen_del/gen_del_allnow return the op exit status. - 2B-4 write fan-out (mm-plan
2B-4-IMPLEMENTATION.md):-p/-d/-Dwrite to the union of{primary} ∪ {@} ∪ {target}— composed mode only, classic path byte-identical. Whole-ppayloads fan out as(ref, blob, len, qtype)via the additiverec_axis_store_typedwhenvtype != CM_STR(D12), else stringrec_axis_store; binary-payload-on-text-only axes loud-skip;-d/-Dcollapse to idempotentrec_axis_unstore; ref operands are literal u32 or primary reverse-view names; loud partials (attempt-all/report-all/nonzero). A per-slot store-capability table is built at bind time (read-only detection);rec_axis_readbackstays dlsym'd with no CLI surface. - Test plugins: write-capable
librec_axis_fold(.wrstash feeds fill) + string-onlylibrec_axis_plain;test-fanout.shgate + dlopen-onlytests/fanout_verify.cprobe.
Verification
gpg --verify CHECKSUMS.asc
sha256sum -c CHECKSUMS