← older entries

Phase 465 — grouped aggregates over an array expression read the table in row order

The open item of Phase 464: gmean(mean(A)) grouped by a key not in row order cost 0.84 us/row against 0.23 in a WHERE, because each group's rows are spread over the table and the block reader decoded a block per row. A scalar aggregate argument (gmean(mean(A)), gsum(X*2), gmax(A[1,2]), ...) is now evaluated once for every row that survived where, in row order, and each group takes its values from that (_prime_aggrs!; array-valued arguments such as gmeans(A) are not kept per row and behave as before). 100,000 rows, 50 groups: 80 -> 21 ms. Test in taql_query_tests.jl against direct computations (several aggregates, WHERE, HAVING, ROLLUP).

Phase 466 — tests that need real IERS data skip when it cannot be downloaded

On 2026-10-25 datacenter.iers.org answered every request with HTTP 400 ("Request Rejected", from a laptop and from a container alike), so EarthOrientation could not fetch finals2000A and its cache file was overwritten by the error page. Three assertions compare against values computed with real dUT1 / polar motion (the EarthOrientationExt in-range lookup of Phase 220, meas.last of Phase 249, the random meas.epoch sidereal comparison of Phase 344) and failed for a reason outside the package. The test helpers now define _HAVE_EOP (a 2024 date returns real values) and those assertions run only then; the zero-fallback assertions still run. No change to src/.

Phase 467 — the nightly CI job no longer fails the run

The Julia pre (1.14) job failed on the Phase 457-465 merge after 2.5 minutes: the test-only dependency Casacore.jl does not precompile there (casacorecxx_jll has no get_libcasacorecxx_path). It is upstream, so the job is now continue-on-error (still run, still visible, no longer decides whether the workflow is red). No change to src/.

Phase 468 — copying the real MAIN table: time and memory at scale (investigation only)

copyms of the first 200,000 / 400,000 / 800,000 rows of the real ALMA MAIN table (2 KB of DATA per row): 10 s / 13-17 s / 20 s (20,000-40,000 rows/s, so about 5-8 minutes for all 9.8 million rows) and the copy reads back equal. Peak resident memory was 2.3 / 3.8 / 6.9 GB, i.e. about 3-4 times the size of the output and linear in the rows (a --heap-size-hint of 1 GB or 3 GB changed it by 5%, so it is live data, not garbage): every column is read whole, then the tiled writer builds the complete tile file in memory before writing it. Copying a whole 20 GB MS would need on the order of 60-85 GB of memory. A copy that works in row chunks (reading, packing and appending a few hundred thousand rows at a time to the tile file, with the index written at the end) would remove that limit; it needs the tiled and SSM/ISM writers to accept appended chunks and is left as a candidate. No code change.

Phase 469 — copying a large table in chunks

copytable / copyms of a plain table read every column whole and the tiled writer built the complete tile file in memory, so a copy needed 3-4 times the size of its output (Phase 468). A tiled-shape column (DATA, FLAG, ...) of 100,000 rows or more is now handed to the writer as a lazy RowsView of the source column and written a chunk of rows at a time: each chunk (64 MiB of tile file, whole tiles) is read with one block decode, packed and appended to .table.f<n>_TSM1.tmp, which is renamed at the end, and only the small header is written last. The files are byte-identical to the whole-column writer's. A column whose cells differ in shape (or are undefined), a container (storage=:multifile), and a copy inside an edit commit fall back to the old path (the partial file is removed). Real MAIN table, rows copied -> peak resident memory: 400,000: 4.0 -> 2.2 GB, 3,000,000: 3.4 GB physical footprint (7 GB resident, of which 2.3 KB/row is the mapped source file) at 60,000 rows/s; the remaining memory grows by about 1 KB per row (the other columns are still read whole: a whole 9.8-million-row MAIN would need about 11 GB instead of 60-85). Tests in tsm_multicol_tests.jl (several chunk sizes, a row range, shared hypercube of mixed types, ragged fallback, container, no leftover temporary file). Open: the same for the SSM/ISM scalar columns and TiledColumnStMan.

Phase 470 — the chunked copy covers TiledColumnStMan and never-written tiled columns

Phase 469 streamed only TiledShapeStMan groups that have a defined cell. The real MAIN table's UVW (TiledColumnStMan) was still read whole, and FLAG_CATEGORY / WEIGHT_SPECTRUM (declared, never written: every cell undefined) were materialised as millions of empty arrays. The tile-file loop is shared (_stream_tilefile!) and UVW-style columns stream to table.f<n>_TSM0; a group in which no cell is defined is checked a million rows at a time and written as a header with no cube (_write_tsm_all_undefined; a defined cell found anywhere declines to the old path). Copying 150,000 rows of the real MAIN with and without streaming gives byte-identical files in every table directory. Physical footprint copying 3,000,000 rows: 3.4 -> 2.5 GB (about 0.8 KB per row, the scalar columns and their writers); 66,000 rows/s. Tests in tsm_multicol_tests.jl.

Phase 471 — partial copies keep the element type of scalar columns

copytable(...; rows = 1:n) of a scalar column (and the rows of a RefTable selection) built a Vector{Any} of boxed values (8-24 bytes a cell) and used the writers' slow generic loops; only a copy of the whole column kept its type. A range or selection of a scalar column now comes from the column's own typed getindex, so the writers see a Vector{Int32} / Vector{Float64} / ... . Output files are byte-identical (checked on the real MAIN); 3,000,000 rows of the real MAIN: physical footprint 2.5 -> 2.2 GB, 66,000 -> 77,000 rows/s (with --heap-size-hint=600M: 1.5 GB). docs/src/performance.md has a section on copying large tables. Test in writer_tests.jl.

Phase 472 — corruption fuzz of the chunked copy (no bug found)

The streaming copy of Phases 469-471 reads tiled files through lazy RowsView columns and appends whole-tile chunks to a .tmp file, so a corrupted tile header or tile file reaches code the earlier fuzzes (Phases 376, 431, 442) never drove. 8 seeds x 150 randomly corrupted tiled tables (a shared hypercube, a TiledColumnStMan, an all-undefined TiledCellStMan; chunks forced to a few rows) and every 4-byte word of the three tiled headers set to five extreme values (18,360 tables), each copied whole and as a row range: every copy either worked or raised an ordinary error — no crash, no run-away allocation, no hang, and a failed copy leaves no directory (nor .tmp file) behind. The fuzz child (test/corrupt_fuzz_child.jl) gained copy and copywords modes, run from corrupt_tests.jl; its random mode now skips empty files.

Phase 473 — thread stress of the chunked copy (no bug found)

Ten tasks copying the same source table at once — whole, by row range, and through a selection, with the streaming thresholds lowered so every copy appends several chunks — plus readers on the source: every copy equals the serial result and no .tmp file is left (4 and 8 threads x 4 rounds, 16 threads x 10). test/threads_copy_child.jl, run from robustness_tests.jl.

Phase 474 — random differential test of copytable (no bug found)

600 random tables (1-600 rows, 1-3 axis cells of seven element types, undefined cells, shared/single tiled-shape, TiledColumnStMan and scalar columns) copied whole and by random row range under random streaming thresholds (chunks of one tile up to the whole column) equal the selected source rows in every column. Seeded test in tsm_multicol_tests.jl.

Phase 475 — static analysis (JET) pass; junk taql target

JET.report_package gives 283 reports (251 in Phase 399); the growth is match(...) results used without a nothing check in the taql() command parser, which cannot fail because the regex has just matched. One report was real: taql(nothing, "UPDATE ...") (or a number as the target) raised a bare MethodError deep inside; it is now an ArgumentError naming the problem. Test in taql_command_tests.jl.

Phase 476 — chained SSM index buckets: boundary sweep (no bug found)

The index of a long StandardStMan table is chained over several index buckets (Phase 451); a one-Bool-column table has 512-byte buckets, so the chain starts near 60,000 rows. 408 tables of 5-140 x 1024 rows (+-1), both byte orders, read by us and (every seventh, with 100,000 rows) by Casacore.jl, and 10 tables edited in place (cell patches, addrows!) all agree. Seeded test in ssm_tests.jl.

Phase 477 — IncrementalStMan multi-bucket boundary sweep (no bug found)

803 tables of 2,500-8,200 rows with columns that change on every row (3 to 10 buckets, buckets filled by size), read by us and (every 13th) Casacore.jl, and every 7th edited in place and extended with addrows! (also read by Casacore.jl): all agree. Seeded test in ism_writer_tests.jl.

Phase 478 — tiled tables: tile-boundary sweep with edits (no bug found)

220 tables (shared hypercube and TiledColumnStMan, 1000-element Float32 + Bool cells, 1-1050 rows around the 254- and 262-row tile boundaries) read back, edited in place, extended with addrows! and shortened with removerows!; the shared-hypercube ones are also read by Casacore.jl: all agree. Seeded test in tsm_multicol_tests.jl.

Phase 479 — SSM string boundaries (no bug found)

610 tables with String cells around the inline limit (8 bytes) and the 512-byte string-bucket size (with and without extra numeric columns, 1 to 300 strings), then one string grown by 700 bytes in an edit session: our reader and Casacore.jl (a third of the tables) agree and the other cells are unchanged. Seeded test in ssm_tests.jl.

Phase 480 — array-valued IncrementalStMan columns: concrete element type, independent cells

A random edit fuzz of ragged array columns (180 tables: five element types, StandardStMan or IncrementalStMan indirect arrays, both byte orders, one to three sessions of cell sets, addrows!, removerows!; every fourth read by Casacore.jl) found no storage bug, but a whole-column read of an array-valued IncrementalStMan column returned a Vector{Any} (StandardStMan gives a concrete element type) and the cells of one stored run were the SAME array object, so changing one returned cell changed its neighbours. Both fixed; a stored run now yields one array per row. Tests in ism_writer_tests.jl (including a seeded copy of the fuzz).

Phase 481 — returned cells are independent in every column kind (no bug found)

After Phase 480's find, every other column kind was probed for the same defect: identical cells in SSM / ISM fixed and ragged arrays, the three tiled managers, ScaledArray / CompressFloat / BitFlags engines, VirtualTaQL, a forwarded column, a RefTable repeating rows and a ConcatTable. No two cells of one col[:] or selection share memory, and changing a returned cell never changes a later read. The test (api_tests.jl) fails on the Phase 479 code.

Phase 482 — keywords(t) and stdcolumns(name) return copies

Probing the metadata accessors for leaks: columnnames, subtables and columndesc(...).keywords are safe, but keywords(t) returned the table's own record, so changing an array or nested record inside it silently changed the table (and what a later copytable would write), and stdcolumns(name) returned the global MS schema's own vector, so changing it changed the schema for the whole process. Both now return copies. Tests in api_tests.jl, schema_tests.jl (the old test asserted identity).

Phase 483 — columndesc(t, name) returns a copy of the column keywords

The last metadata accessor that handed out the table's own state: changing the QuantumUnits array in columndesc(t, name).keywords changed the table's units for every later read. The keyword record of the result is now a copy (a column without keywords costs nothing). One internal comparison in _dm_local relied on the identity of the descriptor and now compares names. Test in api_tests.jl.

Phase 484 — edit sessions copy the arrays assigned to them

The reverse of Phases 480-483: an edit session kept the CALLER's array, so reusing the buffer before the flush changed what was written — e[:V][2] = v; v .= -1 wrote -1, the same array assigned to two cells and then mutated wrote the mutation, and so did a column given to addcolumn!. t[name][i] = array, t[name][:] = arrays and the data of addcolumn! (also through a RefTable view) now store a copy. insert! and write_table were already safe. Test in edit_tests.jl.

Phase 485 — edit sessions hand out copies of arrays

The read side of Phase 484: e[:V][i] returned the session's own array when the cell had been set or added, or came from a loaded StandardStMan / IncrementalStMan column, so changing the returned array edited the session behind its back — and whether it was written depended on which flush path ran (a tiled column's untouched cells, read from the file, were safe). Arrays read from a session are now copies. Test in edit_tests.jl.

Phase 486 — the limits of the default half-precision DATA, documented and pinned

Quantifying the default ComplexF16 MAIN DATA on the real MS (30,000 rows, 7.7 M values): largest amplitude 175, mean relative error 2·10⁻⁴, 99th percentile 4·10⁻⁴; 22,728 values lie below Float16's normal range and keep fewer bits. The behaviour outside that range was undocumented: a part above 65504 reads back as Inf and one below 6·10⁻⁸ as 0, without a warning. docs/src/guide.md now says so (with BFloat16 / :full for uncalibrated data) and precision_tests.jl pins the stated overflow, underflow and error bound. No behaviour change.

Phase 487 — where the CI time goes (investigation only)

Per-file times of the last green CI run (TEST FILE lines): Julia 1.12 takes 7,309 s of test files against 4,746 s on 1.10 (which also runs coverage and the full fuzz counts), a uniform ~1.5x with no single outlier — taql_query_tests 2,027 vs 1,224 s, taql_command_tests 1,532 vs 872, corrupt_tests 685 vs 376. These are the real-TaQL cross-checks (the same file runs in 88 s locally without Casacore). The job needs about 122 of its 180 minutes, so nothing was changed.

Phase 488 — string literals: adjacent literals concatenate; separators inside a literal

Probing string literals against real TaQL (22 + 18 forms): (1) literals that touch — 'a''b', "a"'b', 'a''' — are ONE string in real TaQL ("ab", "a"; a quote is written with the other quote type, a space between literals is an error); TaQL-lite raised a parse error, so 'it''s' could not be used in a WHERE, an IN [...] list or a LIKE pattern. (2) The INSERT / UPDATE splitters were not quote-aware: VALUES ('a,b', 3) counted three values, VALUES ('f(x', 5) found no value tuple and SET S = 'u,v' a malformed assignment. Both fixed; the 18 forms agree with real TaQL. Tests in taql_query_tests.jl (with the real-TaQL cross-check) and taql_command_tests.jl.

Phase 489 — command syntax: comments, IN (...), keyword case, trailing ;

A probe of 48 command-syntax variants against real TaQL found four gaps. # ... to the end of the line is a comment (ignored inside a string literal); K IN (1,2) is a set like K IN [1,2]; a trailing ; ends a taql() command; and keywords in mixed or lower case (select from t where ..) were not recognised in SELECT and INSERT ... SELECT: a case-insensitive regex built by concatenating regex literals keeps only the first literal's flags, so the i flag was lost (now built once by _rxis). The one remaining difference is the known leniency NOT K==3. Tests in taql_query_tests.jl.

Phase 490 — expression syntax: operator runs, NOT as an operand

97 expression-syntax variants against real TaQL (signs and double signs, nesting, precedence and associativity, literal forms 2. .2e1 0X2, function-name case, rownumber(), LIKE / ~ / IN forms, ...) agree except two gaps. A run of operator characters that is not one operator (!!(K == 2), K==!B) was rejected as a whole; it is now split at the longest known operator. NOT / ! is also an operand (B != !B, K == NOT B: real TaQL binds it tighter than ==); a leading NOT K == 3 still negates the whole comparison (real TaQL calls it an error; kept as a convenience). Test in taql_query_tests.jl.

Phase 491 — UPDATE / DELETE LIMIT follows real TaQL's convention

62 write-command syntax variants (case, newlines, # comments, ;, aliases, quoting, VALUES forms, LIMIT) applied to twin tables, real TaQL against taql(), agree except LIMIT: in real TaQL (the SELECT convention) LIMIT 0 means no limit and LIMIT -n all but the last n rows, where we had "no row" and "the last n". Fixed for the string forms and for the limit= keyword of update! / delete!. Two leniencies stay (an alias qualifier SET t.A = 1, INSERT ... SET ... LIMIT). Test in taql_command_tests.jl, cross-checked on twin tables.

Phase 492 — unnamed computed SELECT columns are Col_<position>

61 SELECT output forms (column lists, AS aliases with or without quotes, expressions, array columns, DISTINCT, ORDER BY / LIMIT / OFFSET, t.A / t.*, aggregates, GROUP BY / HAVING) against real TaQL, comparing the persisted result's column names and values. One gap: an expression without AS is called Col_<position> in real TaQL (SELECT A, B*2 -> A, Col_2; SELECT 1 -> Col_1); it was an error, or for a literal named 1. Not matched: real TaQL's persisted result of GROUP BY ... ORDER BY is not sorted (and with LIMIT has odd counts), where ours is sorted as intended. Tests in taql_command_tests.jl (cross-checked) and taql_query_tests.jl.

Phase 493 — JOIN output names, * under a JOIN, FROM $2

32 JOIN command variants against real TaQL (aliases with or without AS, case, newlines, ;, comments, the order of the two sides in ON, = / == / IN, rowid(), WHERE / ORDER BY / LIMIT / GROUP BY, sub-queries on a second table). Fixed: a qualified projection a.K is the column K (it became Col_1 after Phase 492); SELECT * under a JOIN is the columns of the FIRST table and alias.* those of that table (we returned every column, prefixed); FROM $2 without a JOIN queries the second table (it used the first), so x IN (SELECT .. FROM $2) now works. Not matched: real TaQL's SELECT DISTINCT over a join does not de-duplicate, its ORDER BY puts NaN (an unmatched float) mid-sequence, and it rejects a.*, b.Y; the Phase 492 note on GROUP BY ... ORDER BY was a misreading of the saved result (the table real TaQL returns IS sorted; the persisted file is the unsorted aggregate). Test in taql_command_tests.jl, cross-checked.

Phase 494 — ALTER TABLE / CREATE TABLE syntax variants

39 ALTER TABLE variants (add / drop / rename column and keyword, lists, case, ;, comments, newlines, failing clauses) agree with real TaQL except RENAME KEYWORD K1 TO K2 onto an existing name, a silent no-op there and an error here (kept). 24 CREATE TABLE variants agree once two forms are accepted: a parenthesised column list CREATE TABLE p (A I4, B R8) and no column list at all (an empty table, optionally LIMIT n). (LIMIT -1 crashes real casacore.) Test in taql_command_tests.jl.

Phase 495 — group aggregates over array values pool the elements

56 GROUP BY / HAVING / aggregate variants plus 20 aggregates over array columns against real TaQL. The main gap: a reducing group aggregate over ARRAY values flattens every element of the group into one scalar in real TaQL (gsum(V) of 2-element cells is the sum of all of them, gvariance the variance over all of them); ours returned an element-wise array for gsum / gmean / gmin / gmax / gmedian / gvariance / ..., and raised errors for grms, gproduct, gany(V > 5), gntrue, gnfalse. These aggregates now form a pooled mode (gcount, gfirst, glast, gaggr, ghist and the per-element gs* forms are unchanged). Not matched (real TaQL quirks): HAVING that selects nothing, CUBE (real rejects it; ours works), gfirst / glast (real rejects). Test in taql_query_tests.jl.

Phase 496 — sub-query variants

28 sub-query variants against real TaQL (IN / NOT IN / EXISTS / NOT EXISTS with DISTINCT, ORDER BY, LIMIT, aggregates, expressions and a second table; scalar sub-queries; nested, aliased and * forms of FROM (SELECT ..)) agree except x IN [SELECT ..] (brackets), which is now recognised like the parenthesised form. Real TaQL cannot select FROM a grouped sub-query, ours can. Test in taql_command_tests.jl.

Phase 497 — date literals; quantity literals next to a unitless operand

38 date / time-literal forms and 15 unit-literal forms against real TaQL. (1) Unquoted DATE literals are numbers (MJD days) in real TaQL: 2020-02-12, 2020/02/12, 12Feb2020, 12-Feb-2020, 2020-02-12T10:00:00, 2020/02/12/10:00:00; ours read 2020-02-12 as the subtraction 2006 and rejected the others. (2) A quantity literal next to an operand WITHOUT a unit contributes its raw value (X < 3deg is X < 3, D < 70000s is D < 70000; casacore leaves a unitless operand as it is) where ours raised a DimensionError — the Phase 69 assertion that a unitless column against a dimensional literal is an error was wrong and is replaced (a unit-bearing column against a bare number is still an error, by decision). (3) -12h and the fractional seconds of 12h30m15.5s are literals. Real TaQL's D BETWEEN <date> AND <date> is an error there and works here. Tests in taql_query_tests.jl.

Phase 498 — array subscript syntax vs real TaQL (no bug found)

71 array-subscript variants (single, pair, empty, :, ranges with step, negative and expression subscripts, spaces, reductions over slices, sums / mins / means, masked selections) against real TaQL: 66 select exactly the same rows and are now a permanent cross-check (taql_query_tests.jl, runs where the real-TaQL tooling is present). The other five are leniency on our side (real TaQL rejects V[1] on a 2-D cell, a float subscript, ndim(V[1,:]), and has no value for mean(V[V > 2])).

Phase 499 — the taql docstring and the engine's header comments describe the current surface

The taql docstring still said GROUP BY / aggregates in a SELECT are not supported and omitted JOIN, sub-queries, ALTER / CREATE / DROP, extra tables ($2) and the syntax points checked against real TaQL in Phases 488-498; the header comments of src/taql/ast.jl listed as unsupported what Phases 60-256 added (masked arrays, mscal.*, running*, reshaping, rowid(), substr, aggregates). Rewritten; no behaviour change.

Phase 500 — Julia vs C++ comparison re-run on the real MS (no outlier)

Milestone re-run of the Phase 400 / 450 comparison on the 9,817,600-row ALMA MAIN against real casacore through Casacore.jl: whole columns (TIME 0.28, ANTENNA1 0.66, FLAG_ROW 0.68, UVW 0.91 of the C++ time), row ranges (DATA 0.30, FLAG 0.21), 20,000 random cells (TIME 0.02, ANTENNA1 0.07, FLAG 0.06, DATA 0.004), subtables (CHAN_FREQ 0.65, POINTING.DIRECTION 0.53, POINTING.NAME 0.16, POINTING.TIME 0.05). Geomean 0.17 over 15 lines, every line faster than C++; nothing to fix. (Table open is not comparable: Casacore.jl reuses a cached handle.) Casacore.jl indexes array columns by the trailing axis (col[:, :, a:b]), so a plain col[:] on a 2-D / 3-D column is a DimensionMismatch there; the scratch script handles that. No code change.

Phase 501 — GROUP BY syntax variants vs real TaQL (no bug found)

41 GROUP BY variants (several and expression keys, WHERE / HAVING / ORDER BY / LIMIT / OFFSET, keyword case, aggregate expressions, aggregates without GROUP BY, iif over aggregates) against real TaQL, comparing the persisted columns. 32 agree exactly (a few because both engines reject the form, e.g. lower-case column names) and are now a permanent cross-check (taql_command_tests.jl). The other nine are real-TaQL quirks, not divergences to copy: GIVING of a grouped result keeps the unsorted / unfiltered aggregate (ORDER BY, LIMIT, HAVING G), HAVING NOT agg > x, GROUP BY 1 and GROUP BY G, G are errors there, gfirst / glast of an integer column is rejected there, and AS T fails because T is the boolean literal. A grouped select's returned (non-persisted) real table carries no columns, so GIVING is the only oracle.

Phase 502 — precompile workload; typed GROUP BY keys; unit arithmetic of angles

(1) The first taql(..., "... GROUP BY ... INTO ...") of a session compiled for 19 s (7.5 s for a plain SELECT ... INTO, 2.6 s for the first query, 1.5 s for copyms): the package had no precompile workload. src/precompile.jl (new dependency PrecompileTools) writes a small table in a temporary directory and runs write / read / query / groupby / join / taql SELECT, UPDATE, INSERT, DELETE / edit / copytable / create_ms / validate / copyms while the package is precompiled; the lock registry and the pattern cache it fills are emptied again so no file descriptor or temporary path ends up in the image. First calls drop to 0.01–0.3 s. A fresh-process test checks this (robustness_tests.jl). (2) GROUP BY on the 9.8M-row MAIN used a Dict{Any,..} with a boxed tuple per row (_group_rows); the key columns now go through a function barrier and the dictionary is keyed by a concrete tuple type: one Int key 0.59 s → 0.06 s, two keys 0.88 s → 0.17 s (real TaQL: 0.39 / 0.68 s). All six GROUP BY queries on the real MS give the same columns as real TaQL. (3) Two assertions left over from Phase 497 were stale (X > 1h on a unitless column now compares with 1, as in real TaQL). A real bug found by the seeded Phase 354 fuzz: Unitful turns rad - arcmin (both dimensionless with UnitfulAngles) into a plain number, after which the Phase 497 rule stripped the other literal ((A - 0.37arcmin) > 2.5deg was compared as 0.2 > 2.5); + / - of two dimensionless quantities now keeps the left unit (UnitfulExt).

Reported by a user working with LOFAR data: write_table of a new MAIN into a directory that already holds the subtables did not produce a readable Measurement Set, because a keyword value could not be a table reference (copyms and create_ms build the links themselves). _kw_value now accepts MeasurementSets.SubTable("././ANTENNA") as a TpTable keyword, so write_table(...; keywords=Dict("ANTENNA" => SubTable(...))) links the subtable and setkeyword! can add or replace such a link (a table keyword still cannot become a number). One-line fix suggested by the user; the table is readable by MeasurementSet and by Casacore.jl. Test in keywords_tests.jl.

Phase 504 — write_table stamps the standard MS column keywords

Following the Phase 503 report (assembling a Measurement Set from pieces): write_table already took column types and comments from the standard schema when the table is named "MAIN", "ANTENNA", ... but not the QuantumUnits / MEASINFO / CATEGORY keywords that casacore requires of an MS (Phase 271: it refuses a table without them), so only create_ms / copyms produced a table casacore accepts. write_table now stamps them on a standard table's columns (the same helper as create_ms, _stamp_standard); explicit measures= / units= still override, standard=false turns it off, and a variable-reference column (SPECTRAL_WINDOW.CHAN_FREQ) gets its MEASINFO only when MEAS_FREQ_REF is in the table (otherwise just the unit). The keywords equal create_ms's (test in keywords_tests.jl).

Note on Phase 502: on Julia 1.10 the precompile workload raises the allocation of a ragged SSM-indirect getcolumn from 1.6x to 2.3x the payload (about 370 B per cell, found by the x86-64 Docker run); the Phase 239 guard's bound is now 3x (the bug it was written for added many times the payload).