AtlatestRepositoryfolio

folio / tree / testgate-f4a.sh

1#!/bin/sh
2# gate-f4a.sh -- F4a: typed graph tables + rebuild/incremental convergence.
3#
4# SETUP-FAILED IS A DISTINCT OUTCOME from RED and from GREEN. Where this gate
5# cannot establish its subject it says so in those words and exits 3, claiming
6# nothing about the product. "The check found nothing" and "the check never
7# ran" are two different facts and this file never lets them print the same way.
8#
9# THE TOOLCHAIN IS PINNED HERE, NOT INHERITED
11# The three folio gates in this repo do not agree about which compiler they run
12# under: `gate-f1a.sh` and `gate-f2a.sh` default to
13# `../task-sigil-language-readers/build/dev/bin/sigil` (0.18.3-dev), while
14# `gate-f2b.sh` takes `${SIGIL:-sigil}`. Measured 2026-08-24: PATH sigil is
15# 0.19.2 and the COMMITTED LOCK pins sigil-run 0.18.2, and that pair builds rc=0
16# and produces a bundle that dies on load with "unsupported bytecode version 11
17# (expected 10)". The combination that works is PATH sigil plus
18# `SIGIL_REDIRECTS=dev-redirects.sgl`, which resolves the runtime out of the
19# `../sigil` working tree so both halves come from one toolchain.
21# So this gate prints BOTH identities in its banner and probes the artifact
22# BEHAVIOURALLY before asserting anything. A version string is a proxy; asking
23# the binary to do something only a working build can do is the measurement.
25# EVERY ARM STATES A DENOMINATOR, AND THE COUNTING ARMS STATE A FLOOR
27# A count with no denominator is a sample wearing a population's clothes. And
28# an upper bound alone is satisfied by a dead scanner -- "no cycles found" is
29# what a detector that cannot see anything also reports -- so arms 3 and 5 carry
30# a planted positive that MUST be found alongside the negative that must not.
32# Usage: sh test/gate-f4a.sh [arm ...] (no args = all arms)
34set -u
36repo_dir=$(cd "$(dirname "$0")/.." && pwd)
37cd "$repo_dir" || exit 1
39sigil="${SIGIL:-sigil}"
40redirects="${SIGIL_F4A_REDIRECTS:-$repo_dir/dev-redirects.sgl}"
41export SIGIL_REDIRECTS="$redirects"
43# Scratch lives in /tmp. The one exception is `sigil build`, which writes
44# `build/dev/...` under the repo root -- gitignored, but it IS a write. No
45# SOURCE file is touched by this gate.
46work=$(mktemp -d /tmp/gate-f4a.XXXXXX) || { echo "SETUP-FAILED: no temp dir"; exit 3; }
47trap 'rm -rf "$work"' EXIT INT TERM
49FIXTURES="$repo_dir/test/fixtures/f4a/notes"
50PROBE="$repo_dir/tools/f4a_graph_probe.sgl"
52fails=0
53fail () { echo "GATE-F4A FAIL: $*" >&2; fails=$((fails + 1)); }
54setup_failed () { echo "GATE-F4A SETUP-FAILED: $*" >&2; exit 3; }
56plain () { sed 's/\x1b\[[0-9;]*m//g'; }
58want_arm () {
59 [ $# -ge 0 ] || return 1
60 if [ -z "$ARMS" ]; then return 0; fi
61 for a in $ARMS; do [ "$a" = "$1" ] && return 0; done
62 return 1
65ARMS="$*"
67# --------------------------------------------------------------------------
68# The probe wrapper.
70# `tools/f4a_graph_probe.sgl` emits FACTS and never a verdict; every comparison
71# in this file is made here, in the shell, against those facts. If the probe
72# decided pass/fail, a sabotage planted in `(folio graph)` could make the
73# product report that it is fine, and the gate would be asserting the product's
74# opinion of itself.
76# The PROBE_DONE marker is mandatory. Without it, a probe that died inside the
77# product prints nothing and every `grep -c` below returns 0 -- which is exactly
78# what a clean corpus also returns.
79# --------------------------------------------------------------------------
80probe () {
81 out="$work/probe.$$"
82 if ! "$sigil" -L "$repo_dir/build/dev/lib" -L "$repo_dir/src" "$PROBE" "$@" \
83 > "$out" 2>"$out.err"; then
84 echo "PROBE_FAILED $*" >&2
85 head -10 "$out.err" | sed "s/^/ /" >&2
86 cat "$out"
87 return 1
88 fi
89 if ! grep -q '^PROBE_DONE' "$out"; then
90 echo "PROBE_INCOMPLETE $* -- no PROBE_DONE marker; the probe did not run to completion" >&2
91 return 1
92 fi
93 grep -v '\[INFO\]' "$out"
94 return 0
97field () { awk -F'\t' -v n="$2" '$1 == v { print $n }' v="$1"; }
98count_tag () { grep -c "^$1 " || true; }
100new_ws () {
101 d=$(mktemp -d "$work/ws.XXXXXX")
102 cp -r "$FIXTURES" "$d/notes"
103 echo "$d"
106dump_to () {
107 # $1 = workspace, $2 = output file
108 probe dump "$1" ops file | grep '^R ' | cut -f2- > "$2"
111# ==========================================================================
112# BANNER + BEHAVIOURAL PRESENCE PROBE
113# ==========================================================================
115echo "=========================================================================="
116echo "gate-f4a: typed graph tables + rebuild/incremental convergence"
117echo "=========================================================================="
118echo "compiler : $("$sigil" --version 2>&1 | plain | tail -1)"
119echo "compiler path : $(command -v "$sigil" || echo "$sigil")"
120echo "SIGIL_REDIRECTS : $redirects"
121if [ -d "$repo_dir/../sigil" ]; then
122 echo "redirect target : ../sigil $(git -C "$repo_dir/../sigil" rev-parse --short HEAD 2>/dev/null) \
123$(git -C "$repo_dir/../sigil" describe --tags 2>/dev/null)"
124else
125 echo "redirect target : ../sigil ABSENT"
126fi
127echo "folio tree : $(git -C "$repo_dir" rev-parse --short HEAD) on \
128$(git -C "$repo_dir" rev-parse --abbrev-ref HEAD)"
129echo "optimize : dev config, optimize 0 (t-4dfcbd honoured by choice; see the task note)"
130echo
132[ -d "$FIXTURES" ] || setup_failed "fixture corpus missing: $FIXTURES"
134if [ "${F4A_NO_BUILD:-0}" != "1" ]; then
135 echo "building (the artifact under test must match the source that is being asserted about)..."
136 if ! "$sigil" build --redirects "$redirects" > "$work/build.log" 2>&1; then
137 tail -20 "$work/build.log" >&2
138 setup_failed "sigil build failed; see $work/build.log"
139 fi
140fi
142[ -d "$repo_dir/build/dev/lib" ] || setup_failed "build/dev/lib is absent; run \`sigil build\`"
144# PRESENCE, ESTABLISHED BEHAVIOURALLY.
146# `sigil build` exiting 0 is NOT evidence that the artifact works -- measured on
147# this very repo, the committed lock produces a bundle that exits 0 at build and
148# dies at load. So the subject is established by asking the built module to do
149# something only a working build can do: enumerate the model's edge kinds.
150if ! probe vocabulary > "$work/vocab.txt"; then
151 setup_failed "the graph module could not be exercised at all (see above)"
152fi
153kinds=$(count_tag EDGE_KIND < "$work/vocab.txt")
154if [ "$kinds" -eq 0 ]; then
155 setup_failed "the built module reported ZERO built-in edge kinds; the artifact is not the subject"
156fi
157echo "presence : graph module live, $kinds built-in edge kinds enumerated from (folio doc model)"
158echo
160corpus_files=$(find "$FIXTURES" -type f | wc -l | tr -d ' ')
161echo "corpus : $corpus_files files under test/fixtures/f4a/notes"
162echo " ($(find "$FIXTURES" -name '*.sgd' | wc -l | tr -d ' ') .sgd, \
163$(find "$FIXTURES" -name '*.md' | wc -l | tr -d ' ') .md)"
164echo
165# ==========================================================================
166# ARM 1 -- FULL REBUILD VERSUS EVERY MUTATION SEQUENCE
167# ==========================================================================
169# The phase's centre. Each sequence drives the INCREMENTAL entry points over a
170# fresh workspace, dumps the canonical relation, then rebuilds from scratch in
171# the same database and dumps again. The two dumps must be byte-identical.
173# What "logically" means is decided by `graph-relation-dump`, and its exclusions
174# are argued in the task note: no `path`, no `indexed_at`, no rowids, no
175# `revisions` log; edges and diagnostics compared as MULTISETS.
177# THE DIFFER IS CONTROLLED BEFORE IT IS TRUSTED. A comparison that cannot
178# report a difference reports "converged" on every input, which is this estate's
179# most-repeated false green. Control 1a perturbs a dump by one line and
180# requires the comparison to notice.
181if want_arm 1; then
182 echo "--- arm 1: rebuild vs every mutation sequence -------------------------"
183 arm1_fails=$fails
184 seqs=0
185 converged=0
187 # ---- control 1a: the comparison can see a difference ----
188 ws=$(new_ws)
189 probe rebuild "$ws" ops file > /dev/null || fail "arm1 control: rebuild failed"
190 dump_to "$ws" "$work/ctl.txt"
191 sed '1d' "$work/ctl.txt" > "$work/ctl-perturbed.txt"
192 if diff -q "$work/ctl.txt" "$work/ctl-perturbed.txt" > /dev/null 2>&1; then
193 fail "arm1 control 1a: the comparison reported IDENTICAL for two files that differ by one line."
194 echo " Every 'converged' below would be meaningless. Fix the comparison first." >&2
195 else
196 echo " control 1a : comparison sees a one-line perturbation ... OK"
197 fi
198 ctl_lines=$(wc -l < "$work/ctl.txt" | tr -d ' ')
199 if [ "$ctl_lines" -lt 100 ]; then
200 fail "arm1 control 1a: the relation dump is only $ctl_lines lines; the corpus should produce hundreds."
201 fi
202 echo " control 1a : baseline relation dump is $ctl_lines lines"
204 # ---- the sequences ----
205 # $1 = label, $2 = shell function name applied to \$ws
206 run_seq () {
207 label=$1; body=$2
208 seqs=$((seqs + 1))
209 ws=$(new_ws)
210 if ! $body "$ws" > "$work/seq.log" 2>&1; then
211 fail "arm1 [$label]: the mutation sequence itself failed"
212 sed 's/^/ /' "$work/seq.log" | head -8 >&2
213 return
214 fi
215 dump_to "$ws" "$work/incr.txt"
216 probe rebuild "$ws" ops file > /dev/null || { fail "arm1 [$label]: rebuild failed"; return; }
217 dump_to "$ws" "$work/rebuilt.txt"
218 a=$(wc -l < "$work/incr.txt" | tr -d ' ')
219 b=$(wc -l < "$work/rebuilt.txt" | tr -d ' ')
220 if [ "$a" -eq 0 ] || [ "$b" -eq 0 ]; then
221 # A FLOOR ON THE COMPARISON ITSELF. Two empty dumps are identical, and an
222 # arm that scored that as convergence would pass hardest exactly when the
223 # indexer had stopped working.
224 fail "arm1 [$label]: a dump was EMPTY (incr=$a rebuild=$b); two empty dumps compare equal"
225 return
226 fi
227 if diff -u "$work/incr.txt" "$work/rebuilt.txt" > "$work/diff.txt" 2>&1; then
228 converged=$((converged + 1))
229 echo " seq $seqs [$label]: incr=$a rebuild=$b lines ... CONVERGED"
230 else
231 fail "arm1 [$label]: incremental and rebuild DIVERGE (incr=$a rebuild=$b)"
232 head -25 "$work/diff.txt" | sed 's/^/ /' >&2
233 fi
234 }
236 seq_forward () {
237 for f in $(find "$1/notes" -type f | sort); do
238 probe index "$1" ops file "$f" > /dev/null || return 1
239 done
240 }
241 seq_reverse () {
242 for f in $(find "$1/notes" -type f | sort -r); do
243 probe index "$1" ops file "$f" > /dev/null || return 1
244 done
245 }
246 seq_twice () {
247 seq_forward "$1" || return 1
248 seq_forward "$1" || return 1
249 }
250 seq_edit () {
251 seq_forward "$1" || return 1
252 printf '\n@p{An appended paragraph mentioning [[topics/workflow]].}\n' >> "$1/notes/topics/beta.sgd"
253 probe index "$1" ops file "$1/notes/topics/beta.sgd" > /dev/null || return 1
254 }
255 seq_delete () {
256 seq_forward "$1" || return 1
257 rm "$1/notes/topics/workflow.sgd"
258 probe remove "$1" ops file "ops::topics/workflow::sgd" > /dev/null || return 1
259 }
260 seq_delete_readd () {
261 cp "$1/notes/topics/workflow.sgd" "$work/workflow.keep"
262 seq_forward "$1" || return 1
263 rm "$1/notes/topics/workflow.sgd"
264 probe remove "$1" ops file "ops::topics/workflow::sgd" > /dev/null || return 1
265 cp "$work/workflow.keep" "$1/notes/topics/workflow.sgd"
266 probe index "$1" ops file "$1/notes/topics/workflow.sgd" > /dev/null || return 1
267 }
268 # A LINK TARGET APPEARING LATE. This is the sequence that catches an edge
269 # resolution frozen at insert time: alpha points at gamma, and here gamma is
270 # indexed AFTER alpha, so an implementation that decided alpha's verdict when
271 # alpha was touched would record `missing` and a rebuild would not.
272 seq_target_late () {
273 for f in $(find "$1/notes" -type f ! -name 'gamma.sgd' | sort); do
274 probe index "$1" ops file "$f" > /dev/null || return 1
275 done
276 probe index "$1" ops file "$1/notes/topics/gamma.sgd" > /dev/null || return 1
277 }
278 # The mirror: a link target REMOVED after everything resolved against it.
279 seq_target_removed () {
280 seq_forward "$1" || return 1
281 rm "$1/notes/topics/gamma.sgd"
282 probe remove "$1" ops file "ops::topics/gamma::sgd" > /dev/null || return 1
283 }
284 # ONE SIDE OF A COLLISION ARRIVES LAST -- the slug genuinely goes from sole to
285 # contested mid-sequence.
286 #
287 # THE SECOND SIDE IS HELD OFF DISK, and that is the whole sequence. The first
288 # version of this only varied INDEX ORDER: `new_ws` copies the entire fixture
289 # tree up front, so both sides were on disk from step one, the census reported
290 # both contested immediately, and this ran the same experiment as
291 # `seq_forward`. It converged, and it was testing nothing this file did not
292 # already test. Found by an adversarial review; the transition it was named
293 # for was a real convergence defect, and this shape is what reddens on it.
294 seq_collision_late () {
295 mv "$1/notes/topics/contested.md" "$work/contested-held.md" || return 1
296 for f in $(find "$1/notes" -type f | sort); do
297 probe index "$1" ops file "$f" > /dev/null || return 1
298 done
299 mv "$work/contested-held.md" "$1/notes/topics/contested.md" || return 1
300 probe index "$1" ops file "$1/notes/topics/contested.md" > /dev/null || return 1
301 }
302 # The mirror: a collision DISSOLVES, and the survivor must stop being
303 # contested. A reconciler that only ever sets the flag passes the sequence
304 # above and fails this one.
305 seq_collision_dissolved () {
306 seq_forward "$1" || return 1
307 rm "$1/notes/topics/contested.md"
308 probe remove "$1" ops file "ops::topics/contested::markdown" > /dev/null || return 1
309 }
310 # A RENAME THAT CHANGES THE SLUG, applied incrementally. The new path indexes
311 # under a NEW uid, so nothing the caller can say identifies the old document --
312 # `delete-document-rows!` keys on the uid it is given. Before the reconciler,
313 # the old document's rows survived forever, including edges still `resolved`
314 # against a document that no longer existed (measured: incr 612 vs rebuild 593).
315 # Arm 6(c) covers rename via a full REBUILD, so this path had no coverage.
316 seq_rename_incremental () {
317 seq_forward "$1" || return 1
318 mv "$1/notes/topics/workflow.sgd" "$1/notes/topics/workflow-renamed.sgd"
319 probe index "$1" ops file "$1/notes/topics/workflow-renamed.sgd" > /dev/null || return 1
320 }
322 run_seq "forward" seq_forward
323 run_seq "reverse" seq_reverse
324 run_seq "index-twice" seq_twice
325 run_seq "edit-in-place" seq_edit
326 run_seq "delete" seq_delete
327 run_seq "delete-then-readd" seq_delete_readd
328 run_seq "target-late" seq_target_late
329 run_seq "target-removed" seq_target_removed
330 run_seq "collision-late" seq_collision_late
331 run_seq "collision-dissolved" seq_collision_dissolved
332 run_seq "rename-incremental" seq_rename_incremental
334 echo " DENOMINATOR : $seqs mutation sequences over a $corpus_files-file corpus; \
335$converged converged, $((seqs - converged)) diverged"
336 if [ "$seqs" -lt 11 ]; then
337 fail "arm1: only $seqs sequences ran; the arm is specified with 11"
338 fi
339 [ "$fails" -eq "$arm1_fails" ] && echo "arm 1 OK" || echo "arm 1 FAILED"
340 echo
341fi
342# ==========================================================================
343# ARM 2 -- BACKLINKS BY TYPE
344# ==========================================================================
346# Membership is asserted EXACTLY, not by count. A count is satisfied by the
347# right number of wrong answers, and this arm exists because "who points at
348# this, and how" is the query the whole edge table is for.
349if want_arm 2; then
350 echo "--- arm 2: backlinks by type -----------------------------------------"
351 arm2_fails=$fails
352 ws=$(new_ws)
353 probe rebuild "$ws" ops file > /dev/null || fail "arm2: rebuild failed"
355 beta="ops::topics/beta::sgd#"
356 probe backlinks "$ws" ops file "$beta" > "$work/bl.txt" || fail "arm2: backlinks probe failed"
358 # Expected, derived from the fixtures rather than from a memory of them:
359 # alpha declares depends-on -> beta (1)
360 # alpha's prose mentions -> beta (2, deliberate twins)
361 # plain.md mentions -> beta (1)
362 # alpha declares invented-here -> beta (EXCLUDED: not a built-in kind)
363 got_depends=$(grep -c "^BACKLINK depends-on ops::topics/alpha::sgd#$" "$work/bl.txt" || true)
364 got_mentions=$(grep -c "^BACKLINK mentions " "$work/bl.txt" || true)
365 got_invented=$(grep -c "^BACKLINK invented-here " "$work/bl.txt" || true)
366 total=$(grep -c '^BACKLINK ' "$work/bl.txt" || true)
367 kinds=$(field BACKLINK_KINDS 2 < "$work/bl.txt")
369 [ "$got_depends" -eq 1 ] || fail "arm2: expected exactly 1 depends-on backlink from alpha, got $got_depends"
370 [ "$got_mentions" -eq 3 ] || fail "arm2: expected 3 mentions backlinks (2 twins from alpha + 1 from plain.md), got $got_mentions"
371 # THE EXCLUSION, ASSERTED RATHER THAN DOCUMENTED.
372 [ "$got_invented" -eq 0 ] || fail "arm2: an UNREGISTERED edge kind appeared in a built-in invariant result"
373 [ "$total" -eq 4 ] || fail "arm2: expected 4 backlink rows in total, got $total"
374 [ "$kinds" = "2" ] || fail "arm2: expected 2 distinct kinds, got '$kinds'"
376 # A node-level backlink, so the arm is not only exercising document nodes.
377 qopen="ops::topics/questions::sgd#0.0"
378 probe backlinks "$ws" ops file "$qopen" > "$work/bl2.txt" || fail "arm2: node backlinks probe failed"
379 ans=$(grep -c "^BACKLINK answers ops::topics/gamma::sgd#$" "$work/bl2.txt" || true)
380 [ "$ans" -eq 1 ] || fail "arm2: expected gamma's answers edge to land on the q-open NODE, got $ans"
382 # A FLOOR ON THE INSTRUMENT. A backlink query that returned nothing for
383 # everything would satisfy every "expected 0" above. Something must come back.
384 [ "$total" -gt 0 ] || fail "arm2: the backlink query returned nothing at all"
386 echo " DENOMINATOR : 2 targets queried; \
387$(probe edges "$ws" ops file | grep -c '^EDGE ' || true) edges in the graph, \
388$(probe edges "$ws" ops file | grep -c ' 1 1 ' || true) of them declared+built-in"
389 echo " beta : depends-on=$got_depends mentions=$got_mentions unregistered=$got_invented total=$total kinds=$kinds"
390 [ "$fails" -eq "$arm2_fails" ] && echo "arm 2 OK" || echo "arm 2 FAILED"
391 echo
392fi
394# ==========================================================================
395# ARM 3 -- DEPENDENCY CYCLES (cap AND floor)
396# ==========================================================================
398# "No cycles found" is what a working detector on a clean graph reports and it
399# is also what a detector that cannot see anything reports. The folio 0.2.10
400# integrity scanner said "No buried task ids found" over a corpus holding six.
401# So this arm requires BOTH: the planted cycle is found, and removing the one
402# edge that closes it makes the count drop to zero.
404# The closing edge is spelled `blocks`, from the far end. A detector that only
405# followed `depends-on` would call this corpus a clean DAG.
406if want_arm 3; then
407 echo "--- arm 3: dependency cycles -----------------------------------------"
408 arm3_fails=$fails
410 ws=$(new_ws)
411 probe rebuild "$ws" ops file > /dev/null || fail "arm3: rebuild failed"
412 probe cycles "$ws" ops file > "$work/cy.txt" || fail "arm3: cycles probe failed"
413 n=$(field CYCLE_COUNT 2 < "$work/cy.txt")
414 ring=$(grep '^CYCLE ' "$work/cy.txt" | head -1 | cut -f2)
416 # FLOOR: the planted cycle must be found, and it must be THE planted one.
417 [ "$n" = "1" ] || fail "arm3 FLOOR: expected exactly 1 cycle in the corpus, got '$n'"
418 case "$ring" in
419 *"topics/alpha"*"topics/beta"*"topics/gamma"*"topics/alpha"*) : ;;
420 *) fail "arm3 FLOOR: the reported cycle is not alpha->beta->gamma->alpha: '$ring'" ;;
421 esac
423 # CAP: remove the one closing edge and the count must go to zero. Without
424 # this, "1 cycle" could be produced by a detector that reports a cycle for
425 # any graph at all.
426 ws2=$(new_ws)
427 sed -i '/CYCLE-CLOSING EDGE/d' "$ws2/notes/topics/alpha.sgd"
428 probe rebuild "$ws2" ops file > /dev/null || fail "arm3: control rebuild failed"
429 probe cycles "$ws2" ops file > "$work/cy2.txt" || fail "arm3: control cycles probe failed"
430 n2=$(field CYCLE_COUNT 2 < "$work/cy2.txt")
431 [ "$n2" = "0" ] || fail "arm3 CAP: with the closing edge removed the corpus is a DAG, but $n2 cycles were reported"
433 # The control must have actually changed the input, or the CAP is a
434 # measurement of the same file twice.
435 if diff -q "$ws/notes/topics/alpha.sgd" "$ws2/notes/topics/alpha.sgd" > /dev/null 2>&1; then
436 fail "arm3: the CAP control did not change alpha.sgd; the sed matched nothing"
437 fi
439 dep_edges=$(probe edges "$ws" ops file | grep -cE ' (depends-on|blocks) ' || true)
440 echo " DENOMINATOR : $dep_edges dependency-relation edges (depends-on + blocks) over $corpus_files files"
441 echo " FLOOR : planted cycle found ($n) -- $ring"
442 echo " CAP : closing edge removed -> $n2 cycles"
443 [ "$fails" -eq "$arm3_fails" ] && echo "arm 3 OK" || echo "arm 3 FAILED"
444 echo
445fi
447# ==========================================================================
448# ARM 4 -- DECISION SUPERSESSION
449# ==========================================================================
450if want_arm 4; then
451 echo "--- arm 4: decision supersession -------------------------------------"
452 arm4_fails=$fails
453 ws=$(new_ws)
454 probe rebuild "$ws" ops file > /dev/null || fail "arm4: rebuild failed"
455 probe supersessions "$ws" ops file > "$work/sup.txt" || fail "arm4: supersessions probe failed"
456 n=$(field SUPERSESSION_COUNT 2 < "$work/sup.txt")
457 [ "$n" = "1" ] || fail "arm4: expected exactly 1 supersession, got '$n'"
459 # The superseding node must be the SUPERSEDED decision's replacement, and both
460 # ends must be real nodes -- an edge to "" would count the same otherwise.
461 line=$(grep '^SUPERSEDES ' "$work/sup.txt" | head -1)
462 from=$(echo "$line" | cut -f2)
463 to=$(echo "$line" | cut -f3)
464 [ -n "$from" ] || fail "arm4: the supersession has an empty source"
465 [ -n "$to" ] || fail "arm4: the supersession has an empty target"
466 probe nodes "$ws" ops file > "$work/nodes.txt" || fail "arm4: nodes probe failed"
467 grep -q "^NODE $from " "$work/nodes.txt" || fail "arm4: superseding uid $from is not a node in the graph"
468 grep -q "^NODE $to " "$work/nodes.txt" || fail "arm4: superseded uid $to is not a node in the graph"
470 # AN INDEPENDENT EXPECTATION, and the two checks above are why it is needed.
471 #
472 # Those compare the product's EDGE output against the product's own NODE
473 # output: a product that minted a consistent wrong uid in both tables passes
474 # them. That is the same defect as a differential over shared code, one level
475 # down. These two assert the `node_id` column against the literals WRITTEN IN
476 # `topics/decisions.sgd`, which no wrong product can move.
477 from_id=$(grep "^NODE $from " "$work/nodes.txt" | head -1 | cut -f8)
478 to_id=$(grep "^NODE $to " "$work/nodes.txt" | head -1 | cut -f8)
479 [ "$from_id" = "d-path-identity" ] \
480 || fail "arm4: the superseding node's id should be d-path-identity (from the fixture), got '$from_id'"
481 [ "$to_id" = "d-path-identity-v0" ] \
482 || fail "arm4: the superseded node's id should be d-path-identity-v0 (from the fixture), got '$to_id'"
484 # The superseded decision must still carry status `superseded` in its fields:
485 # the graph records the edge, the document records the state, and a phase that
486 # made those disagree would have an index asserting something the source does
487 # not say.
488 st=$(grep "^NODE $to " "$work/nodes.txt" | head -1)
489 echo "$st" | grep -q 'decision' || fail "arm4: the superseded node is not a decision: $st"
491 decisions=$(grep -c ' decision ' "$work/nodes.txt" || true)
492 echo " DENOMINATOR : $decisions decision nodes in the corpus; 1 supersedes edge expected, $n found"
493 echo " edge : $from supersedes $to"
494 [ "$fails" -eq "$arm4_fails" ] && echo "arm 4 OK" || echo "arm 4 FAILED"
495 echo
496fi
497# ==========================================================================
498# ARM 5 -- ORPHAN / MISSING / AMBIGUOUS / EXPLICIT CROSS-WORKSPACE
499# ==========================================================================
501# Four verdicts, four separate assertions, because collapsing any two of them is
502# the taxonomy's shape 17 and it is how a duplicated id gets treated as an
503# absent one. `cross-workspace` in particular is NOT a failure: it is the
504# answer "you did not ask me to find this here", and folding it into `missing`
505# would turn every legitimate foreign reference into a broken link.
506if want_arm 5; then
507 echo "--- arm 5: orphan / missing / ambiguous / cross-workspace -------------"
508 arm5_fails=$fails
509 ws=$(new_ws)
510 probe rebuild "$ws" ops file > /dev/null || fail "arm5: rebuild failed"
511 probe unresolved "$ws" ops file > "$work/unres.txt" || fail "arm5: unresolved probe failed"
512 probe orphans "$ws" ops file > "$work/orph.txt" || fail "arm5: orphans probe failed"
513 probe edges "$ws" ops file > "$work/edges.txt" || fail "arm5: edges probe failed"
515 declared=$(grep -c '^EDGE [^ ]* [^ ]* [01] 1 ' "$work/edges.txt" || true)
516 missing=$(grep -c ' missing$' "$work/unres.txt" || true)
517 ambiguous=$(grep -c ' ambiguous$' "$work/unres.txt" || true)
518 crossws=$(grep -c ' cross-workspace$' "$work/unres.txt" || true)
519 unres_total=$(field UNRESOLVED_COUNT 2 < "$work/unres.txt")
520 orphans=$(field ORPHAN_COUNT 2 < "$work/orph.txt")
522 # FLOOR -- one planted instance of each, by exact target.
523 grep -q ' topics/nowhere missing$' "$work/unres.txt" \
524 || fail "arm5 FLOOR: the planted MISSING target topics/nowhere was not reported"
525 grep -q ' topics/contested ambiguous$' "$work/unres.txt" \
526 || fail "arm5 FLOOR: the planted AMBIGUOUS target topics/contested was not reported"
527 grep -q ' other topics/alpha cross-workspace$' "$work/unres.txt" \
528 || fail "arm5 FLOOR: the planted CROSS-WORKSPACE target other:topics/alpha was not reported"
529 grep -q '^ORPHAN ops::topics/orphan::markdown$' "$work/orph.txt" \
530 || fail "arm5 FLOOR: the planted orphan note was not reported"
532 # CAP -- and this is the half that matters. Every declared edge that is NOT
533 # one of the three planted failures must be RESOLVED. Without it, an
534 # implementation that marked everything `missing` would satisfy every FLOOR
535 # above and read as a perfect score.
536 [ "$missing" -eq 1 ] || fail "arm5 CAP: expected exactly 1 missing target, got $missing"
537 [ "$ambiguous" -eq 1 ] || fail "arm5 CAP: expected exactly 1 ambiguous target, got $ambiguous"
538 [ "$crossws" -eq 1 ] || fail "arm5 CAP: expected exactly 1 cross-workspace target, got $crossws"
539 [ "$unres_total" = "3" ] || fail "arm5 CAP: expected 3 unresolved declared edges in total, got $unres_total"
541 # AMBIGUOUS IS NOT MISSING -- asserted as a distinction, not assumed. The
542 # contested slug HAS two documents in the graph; a lookup that answered
543 # "missing" for it would be claiming the notes are absent when both are here.
544 probe documents "$ws" ops file > "$work/docs.txt" || fail "arm5: documents probe failed"
545 contested_docs=$(grep -c '^DOCUMENT ops::topics/contested::' "$work/docs.txt" || true)
546 [ "$contested_docs" -eq 2 ] \
547 || fail "arm5: the ambiguous slug should have 2 documents indexed, got $contested_docs"
549 # Orphan CAP: a note that IS linked must not be listed.
550 grep -q '^ORPHAN ops::topics/alpha::sgd$' "$work/orph.txt" \
551 && fail "arm5 CAP: alpha is heavily linked and must not be reported as an orphan"
553 # THE DIAGNOSTICS TABLE, ASSERTED RATHER THAN MERELY DUMPED.
554 #
555 # Diagnostic rows are inside the convergence relation, so arm 1 catches a
556 # DIVERGENCE in them -- but `index-source!` guards every per-file failure into
557 # one `index-failure` row, and BOTH paths take that guard identically, so a
558 # document that had become unindexable would converge perfectly. Nothing else
559 # in this gate reads diagnostic CONTENT. Two literals close it: every fixture
560 # is indexed, and none of them failed.
561 probe diagnostics "$ws" ops file > "$work/diag5.txt" || fail "arm5: diagnostics probe failed"
562 doc_count=$(field DOCUMENT_COUNT 2 < "$work/docs.txt")
563 [ "$doc_count" = "$corpus_files" ] \
564 || fail "arm5: expected $corpus_files documents indexed, got $doc_count"
565 idxfail=$(grep -c ' index-failure ' "$work/diag5.txt" || true)
566 [ "$idxfail" -eq 0 ] \
567 || fail "arm5: $idxfail document(s) failed to index -- the guard turned a defect into a diagnostic row"
568 contested_diags=$(grep -c ' contested-source ' "$work/diag5.txt" || true)
569 [ "$contested_diags" -eq 2 ] \
570 || fail "arm5: expected 2 contested-source diagnostics (one per side of the collision), got $contested_diags"
572 # D12's SECOND GRAMMAR. `[[t-rebuild]]` is a NODE ID, not a document
573 # address, and nothing in the syntax says which a bare target is. Measured
574 # over the live estate: 4,510 of 29,260 link occurrences are task ids -- the
575 # largest single class -- and every one read `missing` before a bare target
576 # was tried as a node id. A broken-links report in which 82% of the entries
577 # are correct references into another address space is one nobody can act on.
578 #
579 # Its own workspace, so arm 5's literal counts above stay put. Both
580 # directions: a real node id resolves TO THE NODE, an invented one stays
581 # missing -- without the second half this passes on a module that resolves
582 # every bare target to something.
583 ws5=$(new_ws)
584 printf 'Tags: f4a\n# Node ref\n\nBy node id [[t-rebuild]], and [[t-nosuchtask]] which does not exist.\n' \
585 > "$ws5/notes/topics/noderef.md"
586 probe rebuild "$ws5" ops file > /dev/null || fail "arm5: node-id rebuild failed"
587 probe edges "$ws5" ops file > "$work/e5b.txt" || fail "arm5: node-id edges probe failed"
588 nid_res=$(awk -F'\t' '$1 == "EDGE" && $8 == "t-rebuild" { print $11 }' "$work/e5b.txt" | head -1)
589 nid_uid=$(awk -F'\t' '$1 == "EDGE" && $8 == "t-rebuild" { print $10 }' "$work/e5b.txt" | head -1)
590 bad_res=$(awk -F'\t' '$1 == "EDGE" && $8 == "t-nosuchtask" { print $11 }' "$work/e5b.txt" | head -1)
591 [ "$nid_res" = "resolved" ] \
592 || fail "arm5: a bare node-id target read '$nid_res'; D12's second grammar is not being tried"
593 case "$nid_uid" in
594 *"::sgd#"*) : ;;
595 *) fail "arm5: the node-id target resolved to '$nid_uid', which is not a node inside a document" ;;
596 esac
597 [ "$bad_res" = "missing" ] \
598 || fail "arm5 RESTRAINT: an invented node id read '$bad_res', not 'missing'; \
599bare targets are resolving to something regardless of whether it exists"
601 echo " node ids : [[t-rebuild]] -> $nid_uid; [[t-nosuchtask]] -> $bad_res"
602 echo " diagnostics : $(field DIAGNOSTIC_COUNT 2 < "$work/diag5.txt") rows; \
603index-failure=$idxfail contested-source=$contested_diags; $doc_count of $corpus_files files indexed"
604 echo " DENOMINATOR : $declared declared edges; $unres_total unresolved \
605(missing=$missing ambiguous=$ambiguous cross-workspace=$crossws), $((declared - unres_total)) resolved"
606 echo " orphans : $orphans of $(field DOCUMENT_COUNT 2 < "$work/docs.txt") documents"
607 [ "$fails" -eq "$arm5_fails" ] && echo "arm 5 OK" || echo "arm 5 FAILED"
608 echo
609fi
611# ==========================================================================
612# ARM 6 -- PATH/PROVIDER MOVE WITH STABLE WORKSPACE-QUALIFIED IDENTITY
613# ==========================================================================
615# Three obligations, and the third is the one that keeps the first honest:
617# (a) moving the root AND changing the provider changes no uid at all;
618# (b) the `path` column DOES follow the move -- provenance stays true. Path
619# is excluded from the convergence relation on purpose, so nothing else
620# in this gate would notice if it went stale, and a stale absolute path
621# pointing into a deleted directory reads as corruption;
622# (c) FLOOR: something that SHOULD move identity does. Renaming a note so its
623# slug changes must produce a different doc uid. Without (c), an
624# implementation whose uids never changed for any reason would score a
625# perfect (a).
626if want_arm 6; then
627 echo "--- arm 6: path/provider move with stable identity --------------------"
628 arm6_fails=$fails
630 ws=$(new_ws)
631 probe rebuild "$ws" ops file > /dev/null || fail "arm6: rebuild failed"
632 dump_to "$ws" "$work/before.txt"
633 probe documents "$ws" ops file | grep '^DOCUMENT ' | cut -f2 | sort > "$work/uids-before.txt"
634 before_path=$(probe documents "$ws" ops file | grep '^DOCUMENT ops::topics/alpha::sgd ' | cut -f5)
636 # The move: a different directory AND a different provider name.
637 moved="$work/moved.$$"
638 mv "$ws" "$moved" || setup_failed "arm6: could not move the workspace"
639 probe remount "$moved" ops git > /dev/null || fail "arm6: remount failed"
641 dump_to "$moved" "$work/after.txt"
642 probe documents "$moved" ops git | grep '^DOCUMENT ' | cut -f2 | sort > "$work/uids-after.txt"
643 after_path=$(probe documents "$moved" ops git | grep '^DOCUMENT ops::topics/alpha::sgd ' | cut -f5)
645 # AN INDEPENDENT FLOOR UNDER (a). "the uid set is unchanged" is satisfied by
646 # two EMPTY sets, so the size is pinned to the corpus's file count -- a
647 # literal the product cannot move -- before the invariance claim is made.
648 uids_n=$(wc -l < "$work/uids-before.txt" | tr -d ' ')
649 [ "$uids_n" -eq "$corpus_files" ] \
650 || fail "arm6 FLOOR: expected $corpus_files document uids before the move, got $uids_n; \
651an empty set would satisfy the invariance check below"
653 # (a) identity
654 if diff -u "$work/uids-before.txt" "$work/uids-after.txt" > "$work/uid-diff.txt" 2>&1; then
655 echo " identity : $(wc -l < "$work/uids-before.txt" | tr -d ' ') document uids unchanged across the move"
656 else
657 fail "arm6 (a): document uids CHANGED across a path+provider move"
658 head -10 "$work/uid-diff.txt" | sed 's/^/ /' >&2
659 fi
660 if diff -u "$work/before.txt" "$work/after.txt" > "$work/rel-diff.txt" 2>&1; then
661 echo " relation : the full relation dump is unchanged across the move"
662 else
663 fail "arm6 (a): the relation dump changed across a path+provider move"
664 head -15 "$work/rel-diff.txt" | sed 's/^/ /' >&2
665 fi
667 # (b) provenance -- the assertion that pays for excluding `path` from (a).
668 [ -n "$before_path" ] || fail "arm6 (b): could not read alpha's path before the move"
669 [ -n "$after_path" ] || fail "arm6 (b): could not read alpha's path after the move"
670 [ "$before_path" != "$after_path" ] \
671 || fail "arm6 (b): the path column did NOT follow the move (still $after_path)"
672 case "$after_path" in
673 "$moved"/*) echo " provenance : path followed the move -> $after_path" ;;
674 *) fail "arm6 (b): the path column does not point into the new root: $after_path" ;;
675 esac
676 [ -f "$after_path" ] || fail "arm6 (b): the recorded path does not exist on disk: $after_path"
678 # (d) THE HARD ONE: a full REBUILD after the move must converge with the
679 # pre-move graph. (a) only shows that nothing was rewritten; this shows that
680 # re-deriving everything from the new location produces the same relation --
681 # which is the actual claim that the relation is path-independent, and the one
682 # that catches a mount point smuggled in through some other column. An
683 # absolute path embedded in a diagnostic MESSAGE is exactly that, and it is
684 # what this check was added for.
685 probe rebuild "$moved" ops git > /dev/null || fail "arm6 (d): rebuild after the move failed"
686 dump_to "$moved" "$work/rebuilt-after-move.txt"
687 if diff -u "$work/before.txt" "$work/rebuilt-after-move.txt" > "$work/move-rb-diff.txt" 2>&1; then
688 echo " path-indep : a full rebuild under the new root converges with the pre-move graph"
689 else
690 fail "arm6 (d): a rebuild after the move does NOT converge with the pre-move graph -- \
691something path-derived is inside the convergence relation"
692 head -15 "$work/move-rb-diff.txt" | sed 's/^/ /' >&2
693 fi
695 # (c) FLOOR -- identity is not simply frozen.
696 ws3=$(new_ws)
697 probe rebuild "$ws3" ops file > /dev/null || fail "arm6: floor rebuild failed"
698 mv "$ws3/notes/topics/workflow.sgd" "$ws3/notes/topics/workflow-renamed.sgd"
699 probe rebuild "$ws3" ops file > /dev/null || fail "arm6: floor re-rebuild failed"
700 probe documents "$ws3" ops file > "$work/docs3.txt" || fail "arm6: floor documents failed"
701 if grep -q '^DOCUMENT ops::topics/workflow::sgd ' "$work/docs3.txt"; then
702 fail "arm6 (c) FLOOR: renaming a note to a different SLUG left its old uid in place -- \
703identity is frozen rather than derived, and (a) proves nothing"
704 fi
705 grep -q '^DOCUMENT ops::topics/workflow-renamed::sgd ' "$work/docs3.txt" \
706 || fail "arm6 (c) FLOOR: the renamed note did not appear under its new uid"
708 echo " DENOMINATOR : $(wc -l < "$work/uids-before.txt" | tr -d ' ') documents moved; \
7091 slug rename as the floor"
710 [ "$fails" -eq "$arm6_fails" ] && echo "arm 6 OK" || echo "arm 6 FAILED"
711 echo
712fi
713# ==========================================================================
714# ARM 7 -- CORRUPT / MISSING DATABASE RECOVERY
715# ==========================================================================
717# Recovery is not "did not crash". A database that opens and answers WRONG is
718# worse than one that refuses, so this arm requires three things of each case:
719# the open reports the right STATE (`created` / `recovered` / `intact` -- three
720# answers, never one), the rebuilt graph converges with the pre-damage graph,
721# and -- the floor -- an undamaged open reports `intact`, so "recovered" is not
722# simply what this code says every time.
723if want_arm 7; then
724 echo "--- arm 7: corrupt / missing database recovery ------------------------"
725 arm7_fails=$fails
726 cases=0
727 recovered=0
729 ws=$(new_ws)
730 st=$(probe rebuild "$ws" ops file | field OPEN 5)
731 [ "$st" = "created" ] || fail "arm7: a workspace with no database should open 'created', got '$st'"
732 dump_to "$ws" "$work/healthy.txt"
733 healthy_lines=$(wc -l < "$work/healthy.txt" | tr -d ' ')
734 [ "$healthy_lines" -gt 100 ] || fail "arm7: the healthy baseline is only $healthy_lines lines"
736 # FLOOR ON THE STATE REPORT: an untouched database must say `intact`.
737 st=$(probe documents "$ws" ops file | field OPEN 5)
738 [ "$st" = "intact" ] || fail "arm7 FLOOR: an undamaged database opened '$st', not 'intact'; \
739if every open says 'recovered' the cases below assert nothing"
740 echo " floor : undamaged open reports 'intact'"
742 damage () {
743 label=$1; how=$2
744 cases=$((cases + 1))
745 d=$(new_ws)
746 probe rebuild "$d" ops file > /dev/null || { fail "arm7 [$label]: setup rebuild failed"; return; }
747 eval "$how"
748 st=$(probe meta "$d" ops file | field OPEN_STATE 2)
749 case "$label" in
750 missing) want=created ;;
751 *) want=recovered ;;
752 esac
753 if [ "$st" != "$want" ]; then
754 fail "arm7 [$label]: open reported '$st', expected '$want'"
755 return
756 fi
757 recovered=$((recovered + 1))
758 probe rebuild "$d" ops file > "$work/rec-rebuild.txt" \
759 || { fail "arm7 [$label]: rebuild after recovery failed"; return; }
761 # AN INDEPENDENT EXPECTATION BEFORE THE SELF-REFERENTIAL ONE.
762 #
763 # The baseline comparison below is product-vs-product: if row construction
764 # were broken, the healthy dump and the recovered dump would be broken
765 # identically and match. These two are literals -- the corpus's file count,
766 # and a doc uid spelled out here -- so a recovery that produced an empty or
767 # differently-keyed graph fails whatever the baseline says.
768 rec_docs=$(field DOCS 2 < "$work/rec-rebuild.txt")
769 [ "$rec_docs" = "$corpus_files" ] \
770 || fail "arm7 [$label]: recovered graph has $rec_docs documents, expected $corpus_files"
771 probe documents "$d" ops file | grep -q '^DOCUMENT ops::topics/alpha::sgd ' \
772 || fail "arm7 [$label]: the recovered graph does not contain ops::topics/alpha::sgd"
774 dump_to "$d" "$work/rec.txt"
775 if diff -u "$work/healthy.txt" "$work/rec.txt" > "$work/rec-diff.txt" 2>&1; then
776 echo " case $cases [$label]: state=$st, rebuilt graph converges with the healthy baseline"
777 else
778 fail "arm7 [$label]: the recovered graph does NOT match the healthy baseline"
779 head -12 "$work/rec-diff.txt" | sed 's/^/ /' >&2
780 fi
781 }
783 damage missing 'rm -f "$d/.folio-graph.db" "$d/.folio-graph.db-wal" "$d/.folio-graph.db-shm"'
784 damage truncated 'true > "$d/.folio-graph.db"'
785 damage garbage 'head -c 40000 /dev/urandom > "$d/.folio-graph.db"'
786 damage header-only 'printf "SQLite format 3\000this is not a real database" > "$d/.folio-graph.db"'
787 # A structurally VALID SQLite file that is simply not our schema.
788 damage foreign-schema 'probe damage-foreign "$d" > /dev/null'
789 # Our tables, at a schema version these queries were not written for.
790 damage stale-version 'probe damage-version "$d" > /dev/null'
791 # Our schema minus one table -- the shape a partial migration leaves.
792 damage dropped-table 'probe damage-drop-table "$d" > /dev/null'
794 echo " DENOMINATOR : $cases damage cases; $recovered reported the expected open state and reconverged"
795 [ "$cases" -ge 7 ] || fail "arm7: only $cases damage cases ran; the arm is specified with 7"
796 [ "$fails" -eq "$arm7_fails" ] && echo "arm 7 OK" || echo "arm 7 FAILED"
797 echo
798fi
800# ==========================================================================
801# ARM 8 -- ALL FOURTEEN BUILT-IN KINDS, AND THE UNKNOWN-KIND EXCLUSION
802# ==========================================================================
804# Two claims, and the second is the one the plan calls out as deliberate:
805# unknown custom edge kinds remain INSPECTABLE DATA and are EXCLUDED from the
806# built-in invariants. Both halves are asserted -- the unknown edge is in the
807# table with its target resolved, and it is absent from every invariant result a
808# known kind in the same position appears in.
810# The gate also checks this module's ONE restatement. `(folio graph)` carries
811# name-level `container-names` / `work-item-names` sets because the model
812# classifies RECORDS and the inspector hands out NAMES, and no exported
813# name-level classifier exists. A restatement goes stale silently, so the union
814# of those sets plus the relational and record names must be EXACTLY the frozen
815# vocabulary as the artifact reports it.
816if want_arm 8; then
817 echo "--- arm 8: fourteen kinds + unknown-kind exclusion --------------------"
818 arm8_fails=$fails
819 probe vocabulary > "$work/vocab.txt" || fail "arm8: vocabulary probe failed"
821 # The list is ENUMERATED FROM THE MODEL by the product, and compared here
822 # against the plan's fourteen. If the model gains a kind this fails loudly
823 # rather than the graph quietly ignoring it.
824 grep '^EDGE_KIND ' "$work/vocab.txt" | cut -f2 | sort > "$work/kinds.txt"
825 cat > "$work/kinds-expected.txt" <<'EOK'
826answers
827belongs-to
828blocks
829contains
830defines-term
831depends-on
832follows-procedure
833governed-by-workflow
834mentions
835references-term
836related-to
837supersedes
838supports
839transcludes
840EOK
841 if diff -u "$work/kinds-expected.txt" "$work/kinds.txt" > "$work/kd.txt" 2>&1; then
842 echo " kinds : all 14 plan kinds present, enumerated from (folio doc model)"
843 else
844 fail "arm8: the model's built-in edge kinds are not the plan's fourteen"
845 cat "$work/kd.txt" | sed 's/^/ /' >&2
846 fi
848 # The restatement check.
849 grep '^VOCAB_NAME ' "$work/vocab.txt" | cut -f2 | sort > "$work/vocab-names.txt"
850 cat > "$work/vocab-expected.txt" <<'EOV'
851decision
852milestone
853note
854phase
855project
856query
857question
858related
859task
860transclude
861update
862EOV
863 if diff -u "$work/vocab-expected.txt" "$work/vocab-names.txt" > "$work/vd.txt" 2>&1; then
864 echo " vocabulary : frozen names unchanged; graph.sgl's container/work-item split still covers them"
865 else
866 fail "arm8: the frozen vocabulary moved. (folio graph)'s container-names/work-item-names \
867sets are a name-level RESTATEMENT of a categorisation the model makes over records, and they must be revisited."
868 cat "$work/vd.txt" | sed 's/^/ /' >&2
869 fi
871 ws=$(new_ws)
872 probe rebuild "$ws" ops file > /dev/null || fail "arm8: rebuild failed"
873 probe edges "$ws" ops file > "$work/e8.txt" || fail "arm8: edges probe failed"
875 # SUPPORTED MEANS EXERCISED, NOT LISTED.
876 #
877 # The check above only shows the MODEL names fourteen kinds. A graph that
878 # could store none of them would pass it. So every one of the fourteen must
879 # appear as a real edge produced from the fixture corpus, and the ones that
880 # are missing are NAMED -- "13 of 14" with no list is a number nobody can act
881 # on.
882 present=0
883 missing_kinds=""
884 while read -r k; do
885 if grep -q " $k " "$work/e8.txt"; then
886 present=$((present + 1))
887 else
888 missing_kinds="$missing_kinds $k"
889 fi
890 done < "$work/kinds-expected.txt"
891 if [ "$present" -eq 14 ]; then
892 echo " exercised : all 14 built-in kinds occur as real edges in the corpus"
893 else
894 fail "arm8: only $present of 14 built-in edge kinds are exercised by the corpus; missing:$missing_kinds"
895 fi
897 # HALF ONE: the unknown kind is STORED, with its target RESOLVED like any
898 # other edge. "Excluded from invariants" must not have quietly become
899 # "dropped", and it must not have become "left unresolved" either.
900 unknown_line=$(grep ' invented-here ' "$work/e8.txt" | head -1)
901 [ -n "$unknown_line" ] || fail "arm8: the unregistered edge kind is not in the edges table at all"
902 echo "$unknown_line" | grep -q ' 0 1 ' \
903 || fail "arm8: the unregistered edge is not marked kind_known=0/declared=1: $unknown_line"
904 echo "$unknown_line" | grep -q ' resolved$' \
905 || fail "arm8: the unregistered edge's target was not resolved: $unknown_line"
907 # HALF TWO: it is excluded from the invariants. The comparison is made
908 # against a KNOWN kind in the SAME position -- alpha's depends-on also points
909 # at beta -- so the exclusion is shown to be about the KIND and not about the
910 # edge being unreachable.
911 probe backlinks "$ws" ops file "ops::topics/beta::sgd#" > "$work/bl8.txt" || fail "arm8: backlinks failed"
912 grep -q '^BACKLINK depends-on ' "$work/bl8.txt" \
913 || fail "arm8 CONTROL: the known-kind edge in the same position is missing, so the exclusion below proves nothing"
914 grep -q '^BACKLINK invented-here ' "$work/bl8.txt" \
915 && fail "arm8: the unregistered kind appeared in backlinks-by-type"
917 # And in the other two invariants, via a corpus that spells a CYCLE and a
918 # SUPERSESSION in an unregistered kind. If exclusion were only implemented in
919 # the backlink query this is where it shows.
920 ws9=$(new_ws)
921 cat > "$ws9/notes/topics/unknown-cycle.sgd" <<'EOU'
922#lang sigil/doc
923@note["topics/unknown-cycle" title: "Unknown-kind cycle" tags: '("f4a")]
925@p{These two edges spell a cycle and a supersession in a kind nobody registered.
926Stored and readable; invisible to the built-in invariants.}
928@related[document: "topics/unknown-cycle" kind: 'invented-cycle]{a self-loop}
929@related[document: "topics/glossary" node: "term-graph" kind: 'invented-supersede]{not a supersession}
931@end note
932EOU
933 probe rebuild "$ws9" ops file > /dev/null || fail "arm8: unknown-cycle rebuild failed"
934 probe edges "$ws9" ops file > "$work/e9.txt" || fail "arm8: unknown-cycle edges failed"
935 # Count the two NEW unregistered kinds by name. A bare `invented-` prefix
936 # also matches the base corpus's `invented-here`, which made this assertion
937 # fail for the wrong reason on its first run -- a gate bug, and the kind that
938 # would have read as a product defect.
939 stored=$(grep -cE ' invented-(cycle|supersede) ' "$work/e9.txt" || true)
940 [ "$stored" -eq 2 ] || fail "arm8: expected 2 unregistered edges stored, got $stored"
941 grep -q ' invented-here ' "$work/e9.txt" \
942 || fail "arm8: the base corpus's unregistered edge vanished when a second one was added"
943 c9=$(probe cycles "$ws9" ops file | field CYCLE_COUNT 2)
944 # The corpus's own alpha-beta-gamma cycle is still there; the unknown self-loop
945 # must NOT add a second one.
946 [ "$c9" = "1" ] || fail "arm8: an unregistered kind changed the cycle count ($c9, expected 1)"
947 s9=$(probe supersessions "$ws9" ops file | field SUPERSESSION_COUNT 2)
948 [ "$s9" = "1" ] || fail "arm8: an unregistered kind changed the supersession count ($s9, expected 1)"
950 total_edges=$(field EDGE_COUNT 2 < "$work/e9.txt")
951 echo " DENOMINATOR : $total_edges edges; $stored unregistered, stored and readable, \
9520 admitted to backlinks / cycles / supersession"
953 [ "$fails" -eq "$arm8_fails" ] && echo "arm 8 OK" || echo "arm 8 FAILED"
954 echo
955fi
957# ==========================================================================
958# ARM 9 -- FTS TEXT AND SPANS/REVISIONS ARE REAL TABLES DOING REAL WORK
959# ==========================================================================
961# The spec names six table groups. Documents, nodes, edges and diagnostics are
962# asserted by every arm above; FTS and spans/revisions are not, because FTS is a
963# projection (deliberately outside the convergence relation, see the module
964# header) and revisions are an append-only log. So they are covered
965# BEHAVIOURALLY here: a planted term must be FOUND, a term that is not there
966# must NOT be, and a multi-byte span must have a byte offset that differs from
967# its scalar offset -- an ASCII-only corpus satisfies that for free.
968if want_arm 9; then
969 echo "--- arm 9: FTS, spans and revisions ----------------------------------"
970 arm9_fails=$fails
971 ws=$(new_ws)
972 probe rebuild "$ws" ops file > /dev/null || fail "arm9: rebuild failed"
974 hits=$(probe search "$ws" ops file "Πολύγλωσσο" | field HIT_COUNT 2)
975 [ "$hits" = "1" ] || fail "arm9: FTS did not find the planted multi-byte term (hits=$hits)"
976 miss=$(probe search "$ws" ops file "zzzznotpresentzzzz" | field HIT_COUNT 2)
977 [ "$miss" = "0" ] || fail "arm9 RESTRAINT: FTS returned $miss hits for a term that is not in the corpus"
978 echo " fts : planted term found (1), absent term not found (0)"
980 probe spans "$ws" ops file > "$work/spans.txt" || fail "arm9: spans probe failed"
981 span_total=$(field SPAN_COUNT 2 < "$work/spans.txt")
982 [ "$span_total" -gt 0 ] || fail "arm9: no spans were recorded at all"
983 # byte_start != scalar_start somewhere, which can only happen after multi-byte
984 # text. The columns are SPAN owner_kind owner_uid part byte_start byte_length
985 # scalar_start scalar_end line column_no -- so byte_start is $5 and
986 # scalar_start is $7. The first version of this line compared $4 (`part`, a
987 # word) against $6 (`byte_length`, a number); they differ for every row, so it
988 # reported "258 of 258 divergent" and would have passed on a pure-ASCII
989 # corpus. A vacuous assert that prints a large, plausible number.
990 divergent=$(awk -F'\t' '$1 == "SPAN" && $5 != "" && $7 != "" && $5 != $7' "$work/spans.txt" | wc -l | tr -d ' ')
991 same=$(awk -F'\t' '$1 == "SPAN" && $5 != "" && $7 != "" && $5 == $7' "$work/spans.txt" | wc -l | tr -d ' ')
992 [ "$divergent" -gt 0 ] \
993 || fail "arm9: every span has byte_start == scalar_start; the corpus is effectively ASCII \
994and the span columns are not being exercised"
995 # ...AND a floor from the other side: if EVERY span diverged, the two columns
996 # are probably not what this check thinks they are. Most of the corpus is
997 # ASCII and must agree.
998 [ "$same" -gt 0 ] \
999 || fail "arm9: NO span has byte_start == scalar_start; most of this corpus is ASCII, \
1000so the comparison is almost certainly reading the wrong columns"
1001 echo " spans : $span_total spans; $divergent with byte offset != scalar offset, $same equal"
1003 probe revisions "$ws" ops file > "$work/revs.txt" || fail "arm9: revisions probe failed"
1004 rev_total=$(field REVISION_COUNT 2 < "$work/revs.txt")
1005 [ "$rev_total" -eq 12 ] || fail "arm9: expected 12 revision rows after a rebuild, got $rev_total"
1006 bad_rev=$(awk -F'\t' '$1 == "REVISION" && $3 !~ /^sha256:[0-9a-f]{64}$/' "$work/revs.txt" | wc -l | tr -d ' ')
1007 [ "$bad_rev" -eq 0 ] || fail "arm9: $bad_rev revision values are not lowercase sha256: hex"
1009 # The revisions LOG grows on edit while the CURRENT revision is replaced.
1010 # This is the fact that justifies keeping the log out of the convergence
1011 # relation, so it is measured rather than asserted in a comment.
1012 printf '\n@p{edited}\n' >> "$ws/notes/topics/workflow.sgd"
1013 probe index "$ws" ops file "$ws/notes/topics/workflow.sgd" > /dev/null || fail "arm9: re-index failed"
1014 rev_after=$(probe revisions "$ws" ops file | field REVISION_COUNT 2)
1015 [ "$rev_after" -eq 13 ] \
1016 || fail "arm9: the revision LOG should have grown to 13 after one edit, got $rev_after"
1017 cur=$(probe documents "$ws" ops file | grep '^DOCUMENT ops::topics/workflow::sgd ' | wc -l | tr -d ' ')
1018 [ "$cur" -eq 1 ] || fail "arm9: a document must have exactly one CURRENT row, got $cur"
1019 echo " revisions : log $rev_total -> $rev_after after one edit; current rows still 1 per document"
1021 echo " DENOMINATOR : $corpus_files documents; $span_total spans; $rev_after revision observations"
1022 [ "$fails" -eq "$arm9_fails" ] && echo "arm 9 OK" || echo "arm 9 FAILED"
1023 echo
1026# ==========================================================================
1027# ARM 10 -- A PROSE MENTION MUST BE A DOCUMENT ADDRESS
1028# ==========================================================================
1030# `extract-crosslinks` matches `[[...]]` anywhere in the source, and bash spells
1031# its test command `[[ -f "$x" ]]`. Measured over the live estate: 3,804
1032# distinct targets, of which the filter drops 111 -- 49 pasted opaque ids, 24
1033# repo-relative paths, 16 prose placeholders, 12 shell test expressions, 3 bare
1034# anchors -- and NONE of the 111 resolve to a note that exists.
1036# An earlier version of this comment said "1,397 of them shell syntax". That
1037# was the count of targets that do not RESOLVE, not of shell forms, and it was
1038# wrong by about a hundred times. Kept visible rather than edited away.
1040# The filter is D12's document-address grammar, taken from `(folio doc model)`.
1041# This arm asserts FOUR directions, because a filter is exactly the kind of
1042# check that is easy to make too strong and impossible to notice -- and the
1043# aliased-link case below is one it WAS too strong for, dropping 15 real links
1044# in the estate until the target/alias split was added:
1046# (a) a real link becomes an edge
1047# (b) shell syntax in a fenced code block becomes NO edge, and the skipped
1048# count is REPORTED rather than swallowed
1049# (c) RESTRAINT: a genuinely foreign `workspace:slug` target still becomes a
1050# cross-workspace edge -- without this the arm passes on a module that
1051# simply stopped emitting mentions
1052if want_arm 10; then
1053 echo "--- arm 10: prose mentions must be document addresses ------------------"
1054 arm10_fails=$fails
1055 ws=$(mktemp -d "$work/ws10.XXXXXX"); mkdir -p "$ws/notes/topics"
1056 printf 'Tags: t\n# Target\n\nA real note.\n' > "$ws/notes/topics/target.md"
1057 cat > "$ws/notes/index.md" <<'EOF10'
1058Tags: t
1059# Index
1061A real link to [[topics/target]] and a foreign one to [[elsewhere:topics/x]].
1063```sh
1064if [[ -f "$src_bin" ]]; then
1065 [[ "$status" -eq 124 ]] && echo timeout
1066 [[ -n $leaf ]] || exit 1
1068```
1069EOF10
1070 probe rebuild "$ws" ops file > /dev/null || fail "arm10: rebuild failed"
1071 probe edges "$ws" ops file > "$work/e10.txt" || fail "arm10: edges probe failed"
1072 probe diagnostics "$ws" ops file > "$work/d10.txt" || fail "arm10: diagnostics probe failed"
1074 edge_field () { awk -F'\t' -v k="$1" -v n="$2" \
1075 '$1 == "EDGE" && $8 == k { print $n }' "$work/e10.txt" | head -1; }
1077 # (a) the real link is an edge, resolved to the real document
1078 [ "$(edge_field 'topics/target' 11)" = "resolved" ] \
1079 || fail "arm10: the real link did not resolve (got '$(edge_field 'topics/target' 11)')"
1080 [ "$(edge_field 'topics/target' 10)" = "ops::topics/target::markdown#" ] \
1081 || fail "arm10: the real link resolved to '$(edge_field 'topics/target' 10)'"
1083 # (b) THE FILTER. Three bash test expressions, zero edges, and the count said.
1084 shell_edges=$(awk -F'\t' '$1 == "EDGE" && ($8 ~ /\$/ || $8 ~ /"/ || $8 ~ /^ / || $8 ~ /-eq|-f |-n /)' \
1085 "$work/e10.txt" | wc -l | tr -d ' ')
1086 [ "$shell_edges" -eq 0 ] \
1087 || fail "arm10: $shell_edges shell test expression(s) became mentions edges"
1088 total_edges=$(field EDGE_COUNT 2 < "$work/e10.txt")
1089 [ "$total_edges" -eq 2 ] \
1090 || fail "arm10: expected exactly 2 mentions edges (1 local + 1 foreign), got $total_edges"
1091 skipped=$(grep -c ' unaddressable-mention ' "$work/d10.txt" || true)
1092 [ "$skipped" -eq 1 ] \
1093 || fail "arm10: the skipped mentions were not reported as a diagnostic (got $skipped rows)"
1094 grep -q ' unaddressable-mention 3 bracketed' "$work/d10.txt" \
1095 || fail "arm10: the diagnostic does not report 3 skipped spans: $(grep unaddressable "$work/d10.txt")"
1097 # (d) AN ALIASED LINK IS A LINK. `[[target|shown text]]` is ordinary wiki
1098 # syntax and `extract-crosslinks` returns the whole string, alias included, so
1099 # a naive address filter classifies every one of them as unaddressable.
1100 # Measured over the estate: 15 real links dropped that way, found by LISTING
1101 # what the filter removed rather than counting it -- the count said "none of
1102 # the dropped targets resolve", which was true only because the resolve check
1103 # was also looking at the alias.
1104 ws2=$(mktemp -d "$work/ws10b.XXXXXX"); mkdir -p "$ws2/notes/topics"
1105 printf 'Tags: t\n# Target\n\nreal\n' > "$ws2/notes/topics/target.md"
1106 printf 'Tags: t\n# Index\n\nAliased [[topics/target|some display text]].\n' > "$ws2/notes/index.md"
1107 probe rebuild "$ws2" ops file > /dev/null || fail "arm10: alias rebuild failed"
1108 probe edges "$ws2" ops file > "$work/e10b.txt" || fail "arm10: alias edges probe failed"
1109 alias_res=$(awk -F'\t' '$1 == "EDGE" && $8 == "topics/target" { print $11 }' "$work/e10b.txt" | head -1)
1110 [ "$alias_res" = "resolved" ] \
1111 || fail "arm10 (d): an aliased link [[target|text]] did not resolve (got '$alias_res'); \
1112the address filter is eating the alias along with the target"
1113 alias_diag=$(probe diagnostics "$ws2" ops file | grep -c ' unaddressable-mention ' || true)
1114 [ "$alias_diag" -eq 0 ] \
1115 || fail "arm10 (d): an aliased link was reported unaddressable"
1117 # (c) RESTRAINT -- the foreign target still reads cross-workspace
1118 [ "$(edge_field 'topics/x' 11)" = "cross-workspace" ] \
1119 || fail "arm10 RESTRAINT: the foreign target reads '$(edge_field 'topics/x' 11)'; \
1120the filter swallowed it instead of classifying it"
1121 [ "$(edge_field 'topics/x' 7)" = "elsewhere" ] \
1122 || fail "arm10 RESTRAINT: the foreign edge lost its workspace name"
1124 echo " DENOMINATOR : 5 bracketed spans in 1 note; 2 are addresses, 3 are shell syntax"
1125 echo " result : $total_edges edges (1 resolved, 1 cross-workspace), $skipped diagnostic reporting 3 skipped"
1126 [ "$fails" -eq "$arm10_fails" ] && echo "arm 10 OK" || echo "arm 10 FAILED"
1127 echo
1132# ==========================================================================
1133echo "=========================================================================="
1134if [ "$fails" -eq 0 ]; then
1135 echo "GATE-F4A GREEN"
1136 exit 0
1137else
1138 echo "GATE-F4A RED: $fails failure(s)"
1139 exit 1