Make an idle folio server nearly free; cache per-call parses (0.3.2)
Census fast path: each 15 s tick takes one native listing of notes/ and projects/ plus PRAGMA data_version and skips the per-file census when both match the state captured before the last complete census. It arms only when every listing line has 7 fields, no symlink the walk follows is present, every mtime and ctime is at least 2 s old (git's racily-clean rule), and no retryable failure is pending. The full census yields every 32 files. An idle tick drops from 216-611 ms to about 24 ms.
Parse cache (filecache.sgl): project files, the inbox, the buried-id integrity scan and task-notes frontmatter are cached per path, keyed on (dev inode size mtime-ns ctime-ns), and stored only when the signature is stable across the compute and the file is 2 s old.
No index or file format change: 0.3.1 and 0.3.2 share a store.
Tests: test/test-idle-census.sgl (19), test/test-census-bound.mjs (an outside edit reaches backlinks within 17 s of an idle server), and tools/check-task-notes-parity.sgl (differential check on a corpus copy).
CHANGELOG.md | 8 +++
package.sgl | 2 +-
src/folio/filecache.sgl | 89 ++++++++++++++++++++++++++++++
src/folio/inbox.sgl | 21 ++++---
src/folio/index.sgl | 129 ++++++++++++++++++++++++++++++++++++++++++-
src/folio/main.sgl | 4 +-
src/folio/project.sgl | 17 +++++-
src/folio/query.sgl | 2 +-
src/folio/tools.sgl | 94 +++++++++++++++++++++----------
test/test-census-bound.mjs | 117 +++++++++++++++++++++++++++++++++++++++
test/test-idle-census.sgl | 478 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
tools/check-task-notes-parity.sgl | 142 +++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 1059 insertions(+), 44 deletions(-)CHANGELOG.mdmodified
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).## [0.3.2] - Unreleased- Skip the per-file census on idle ticks. Each 15 s tick compares one native metadata listing of `notes/` and `projects/` (size, mtime-ns and ctime-ns of every entry) and SQLite's `data_version` with the last complete census, and runs the full census only when something differs, another connection committed, or a retryable failure is pending. A listing is trusted only once its newest mtime and ctime are 2 s old, so writes inside one coarse timestamp granule cannot hide. A visible symlink, or a listing line that does not parse as 7 fields, disables the skip. On a 4,467-note corpus an idle tick drops from 216-611 ms of CPU to about 24 ms.- Yield every 32 files during a full census, so a large outside change no longer holds a shared HTTP server's only thread while it is reconciled.- Reuse parsed project files, the parsed inbox and buried-id scans while each file's `stat` signature (dev, inode, size, mtime-ns, ctime-ns) is unchanged. A file is cached only once its mtime and ctime are 2 s old; a recently written file is parsed on every call. `tasks`, `projects` and `status` no longer re-parse every project file on each call.- Answer `task-notes` from cached note frontmatter, keyed the same way, instead of parsing every note on each call (6.5 s on the corpus above). The answer is still computed from the files at call time.- No index or file format change: 0.3.1 and 0.3.2 processes share one store and index.## [0.3.1] - 2026-10-22- Recheck file existence inside census removal transactions and forget vanished failure entries. Require offline migration before the first upgraded server start.package.sglmodified
(package name: "folio" version: "0.3.1" version: "0.3.2" sigil: "0.22.15" description: "Markdown-native knowledge and project management via MCP" url: "https://codeberg.org/sigil/folio"src/folio/filecache.sgladded
;;; (folio filecache) - reuse a value computed from a file while the file is unchanged.;;;;;; Read-only tools used to re-parse every project file (and, for task-notes,;;; every note) on every call: 0.6-6.5 s of CPU per call on the 4,467-note;;; corpus, during which a shared HTTP server answers nobody else. A cache;;; entry is keyed on the file's path and reused only while one `stat` still;;; reports the same (dev inode size mtime-ns ctime-ns):;;;;;; - an editor write in the same second moves mtime-ns and ctime-ns;;;; - `touch -d` can restore mtime but never ctime, which every write moves;;;; - a replacement (git checkout, write-file-atomic's rename) is a new inode;;; with a new ctime, even when size and mtime match the old file.;;;;;; File times come from a coarse clock (a few ms on Linux, whole seconds on;;; some filesystems), so two same-size writes inside one granule can share;;; every field. A value is therefore cached only when the file's mtime and;;; ctime are both at least file-cache-settle-seconds old (git's "racily;;; clean" rule): any later write must then move a time. A recently written;;; file is simply parsed again on each call until it settles.;;;;;; The value is cached only when the signature is the same before and after;;; computing it, so a file written while it is being read is never cached;;; with a signature that does not describe the bytes that were parsed.;;; Cached values are shared between callers and must be treated as;;; immutable (folio's structs, dicts and lists are).(define-library (folio filecache) (import (sigil fs) (sigil string) (sigil time)) (export make-file-cache file-cache-ref file-cache-size file-cache-prune! file-cache-settle-seconds) (begin ;;; How old a file's newest time must be before its value is cached. ;;; Tests set it to 0 with (file-cache-settle-seconds 0). (define file-cache-settle-seconds (make-parameter 2)) ;; signature = (dev inode size mtime-ns ctime-ns) (define (settled? signature) (let ((cutoff-ns (* (- (current-second) (file-cache-settle-seconds)) 1000000000))) (and (< (list-ref signature 3) cutoff-ns) (< (list-ref signature 4) cutoff-ns)))) ;; A cache is a box holding a dict: path -> (signature . value). (define (make-file-cache) (: -> vector?) (vector (dict))) ;;; Return (compute path), reusing the value from an earlier call while ;;; the file's stat signature is unchanged. A missing file is never cached. ;;; Errors from compute propagate and cache nothing. (define (file-cache-ref cache path compute) (: vector? string? procedure? -> any?) (let ((before (file-stat-fingerprint path))) (if (not before) (compute path) (let ((hit (dict-ref (vector-ref cache 0) path #f))) (if (and hit (equal? (car hit) before)) (cdr hit) (let* ((value (compute path)) (after (file-stat-fingerprint path))) (when (and (equal? before after) (settled? before)) (vector-set! cache 0 (dict-set (vector-ref cache 0) path (cons before value)))) value)))))) (define (file-cache-size cache) (: vector? -> integer?) (length (dict-keys (vector-ref cache 0)))) ;;; Drop the entries under `dir` whose path is not in `live-paths`, so a ;;; deleted or renamed file does not stay in memory. Entries under other ;;; directories (another store in the same process) are kept. (define (file-cache-prune! cache dir live-paths) (: vector? string? list? -> void?) (let ((prefix (if (string-ends-with? dir "/") dir (string-append dir "/"))) (live (dict)) (entries (vector-ref cache 0))) (for-each (lambda (path) (set! live (dict-set live path #t))) live-paths) (for-each (lambda (path) (when (and (string-starts-with? path prefix) (not (dict-contains? live path))) (set! entries (dict-remove entries path)))) (dict-keys entries)) (vector-set! cache 0 entries)))))src/folio/inbox.sglmodified
(sigil fs) (folio store) (folio task) (folio id)) (folio id) (folio filecache)) (export read-inbox inbox-add! inbox-remove-by-id! ;;; Read all tasks from the inbox. ;;; ;;; Returns a list of folio-task structs with source-file set. ;;; The parse is reused while inbox.md's stat signature is unchanged; see ;;; (folio filecache). (define (read-inbox store) (: folio-store? -> list?) (let ((path (store-inbox-path store))) (if (file-exists? path) (let* ((content (read-file-string path)) (lines (string-split content "\n"))) (map task-block-task (find-task-blocks lines path))) '()))) (file-cache-ref inbox-cache (store-inbox-path store) parse-inbox-file)) (define inbox-cache (make-file-cache)) (define (parse-inbox-file path) (if (file-exists? path) (let* ((content (read-file-string path)) (lines (string-split content "\n"))) (map task-block-task (find-task-blocks lines path))) '())) ;;; Add a task to the inbox. ;;;src/folio/index.sglmodified
(sigil time) (sigil log) (sigil sqlite) (only (sigil async) yield in-async-context?) (folio store) (folio note)) (export make-folio-index folio-index-db index-reconcile! index-census-tick! census-settle-seconds index-watch! index-rebuild! index-migrate! (store) (fingerprints) (failed-fingerprints) (transaction-mutex)) (transaction-mutex) ;; Box: what the last complete census saw (see census-state), or #f. (census-state)) (define (checked-sql result) (unless result (error "Folio index SQL operation failed"))) (folio-index db: db store: store fingerprints: (vector versions) failed-fingerprints: (vector (dict)) transaction-mutex: mutex)))) transaction-mutex: mutex census-state: (vector #f))))) ;; ============================================================ ;; Write-through operations (store (folio-index-store idx)) (notes-dir (store-notes-dir store)) (files (store-note-files store)) (pace (make-census-pacer)) (versions (dict)) (seen (dict)) (note-names (dict)) (query "query:link-sources" "SELECT path, fingerprint FROM link_sources")) (for-each (lambda (path) (pace) (let ((name (note-relative-name path notes-dir))) (set! seen (dict-set seen path #t)) (set! note-names (dict-set note-names name #t)) (query "query:notes" "SELECT name FROM notes_meta")) (for-each (lambda (path) (pace) (set! seen (dict-set seen path #t)) (set! project-paths (dict-set project-paths path #t)) (safe-index-item! idx path (forget-failure! idx key))) (dict-keys (vector-ref box 0)))))) ;; A full census over thousands of files must not hold the server's only ;; thread: when it runs as a task, it yields every census-yield-every ;; files so queued requests are answered between slices. Outside a ;; scheduler (startup, --migrate-index) it never yields. (define census-yield-every 32) (define (make-census-pacer) (let ((n 0)) (lambda () (set! n (+ n 1)) (when (>= n census-yield-every) (set! n 0) (when (in-async-context?) (yield)))))) ;; ============================================================ ;; Census fast path ;; ============================================================ ;;; Seconds a listing's newest mtime and ctime must lie in the past before ;;; the fast path may trust it. File times come from a coarse clock (a ;;; few ms on Linux, whole seconds on some filesystems), so a second ;;; same-size write, or an atomic replace (the listing has no inode), ;;; inside one granule can leave every listed field unchanged. Once every ;;; time is this old, any later write must move a time: git's "racily ;;; clean" rule. Tests set it to 0 with (census-settle-seconds 0). (define census-settle-seconds (make-parameter 2)) ;; What a tick compares with the last complete census, or #f when it ;; cannot be read (the full census then runs, as it always did): ;; ;; - the native metadata listing of notes/ and of projects/: size, ;; mtime-ns and ctime-ns of every entry, directories included. An ;; edit moves the file's times even at the same size; `touch -d` can ;; reset mtime but not ctime; an add, remove, rename or replacement ;; (git checkout) also moves the parent directory's times. Any ;; difference runs the full census, which compares each file's own ;; (dev inode size mtime ctime). ;; - SQLite's data_version, which moves whenever ANOTHER connection ;; commits, so a row written by a different process (a stdio session, ;; an older folio) is always reconciled. ;; ;; file-tree-metadata does not follow symlinks, so an editor's dangling ;; lock link (.#note.md) cannot make it fail. (define (census-state idx) (guard (exn (else #f)) (let* ((store (folio-index-store idx)) (notes (file-tree-metadata (store-notes-dir store))) (projects (file-tree-metadata (store-project-dir store))) (row (sqlite-query-row (folio-index-db idx) "PRAGMA data_version"))) (and notes projects row (list notes projects (cdr (assoc 'data_version row))))))) ;; May the state captured at `captured-seconds`, before a complete ;; census, arm the fast path? Both listings must be settled ;; (listing-settled?) and every remaining failure must be one the census ;; defers until the file's signature changes, which changes the listing. (define (armable? idx state captured-seconds) (let ((cutoff-ns (* (- captured-seconds (census-settle-seconds)) 1000000000))) (and (listing-settled? (car state) cutoff-ns) (listing-settled? (cadr state) cutoff-ns) (let ((failures (vector-ref (folio-index-failed-fingerprints idx) 0))) (let loop ((keys (dict-keys failures))) (or (null? keys) (and (not (cadr (dict-ref failures (car keys)))) (loop (cdr keys))))))))) ;; Every line must parse as exactly 7 fields, so a name holding a newline ;; or a tab cannot hide an entry; no line may be a symlink the store walk ;; would follow (the walk follows them, the listing does not show their ;; target's changes; the walk skips every dot component); and every ;; mtime and ctime must be older than cutoff-ns. (define (listing-settled? listing cutoff-ns) (let loop ((lines (string-split listing "\n"))) (cond ((null? lines) #t) ((equal? (car lines) "") (loop (cdr lines))) (else (let ((fields (string-split (car lines) "\t"))) (and (= (length fields) 7) (not (and (equal? (cadr fields) "l") (not (any-hidden-component? (car fields))))) (let ((mtime (string->number (list-ref fields 4))) (ctime (string->number (list-ref fields 5)))) (and mtime ctime (< mtime cutoff-ns) (< ctime cutoff-ns))) (loop (cdr lines)))))))) (define (any-hidden-component? relative-path) (let loop ((parts (string-split relative-path "/"))) (and (pair? parts) (or (string-starts-with? (car parts) ".") (loop (cdr parts)))))) ;;; One census tick: skip the per-file census when nothing it reads can ;;; have changed since the last complete one, otherwise run it. Returns ;;; 'unchanged or 'reconciled. (define (index-census-tick! idx) (: folio-index? -> symbol?) (let* ((captured (current-second)) (before (census-state idx)) (box (folio-index-census-state idx))) (if (and before (equal? before (vector-ref box 0))) 'unchanged (begin (vector-set! box 0 #f) (index-reconcile! idx) ;; Keep the state seen BEFORE this census, so a change made ;; while it ran differs at the next tick; and only when it is ;; armable, so an unsettled time or a retryable failure keeps ;; the next tick on the full census. (when (and before (armable? idx before captured)) (vector-set! box 0 before)) 'reconciled)))) (define (index-watch! idx) (let loop () (sleep 15) (guard (exn (else (vector-set! (folio-index-census-state idx) 0 #f) (log-error "Index refresh failed; retrying next tick" error: (if (error-object? exn) (error-object-message exn) (format "~s" exn))))) (index-reconcile! idx)) (log-debug "Census tick" result: (index-census-tick! idx))) (loop))) ;;; Rebuild by atomic document replacement. Readers keep the previoussrc/folio/main.sglmodified
(log-warn "Initial index refresh deferred; census will retry" error: (if (error-object? exn) (error-object-message exn) (format "~s" exn))))) (index-reconcile! idx)) ;; A full census (nothing is remembered yet) that also records ;; what it saw, so the first tick can already take the fast path. (index-census-tick! idx)) (if http-port (begin (with-asyncsrc/folio/project.sglmodified
(folio store) (folio task) (folio id) (folio frontmatter)) (folio frontmatter) (folio filecache)) (export folio-project folio-project? folio-project-name folio-project-log-entries read-project read-project-cached project-status project-goal project-area ;; Queries ;; ============================================================ ;; Parsed project files, reused while each file's stat signature is ;; unchanged. See (folio filecache) for why that is safe. (define project-cache (make-file-cache)) ;;; read-project, reusing the parse of an unchanged file. (define (read-project-cached file-path) (: string? -> folio-project?) (file-cache-ref project-cache file-path read-project)) ;;; Load all projects as folio-project structs. (define (all-projects store) (: folio-store? -> list?) (map read-project (store-project-files store))) (let ((files (store-project-files store))) (file-cache-prune! project-cache (store-project-dir store) files) (map read-project-cached files))) ;;; Find a project file by name (filename without .md). (define (find-project-file store name)src/folio/query.sglmodified
(project-tasks (apply append (map (lambda (f) (folio-project-tasks (read-project f))) (folio-project-tasks (read-project-cached f))) project-files)))) (append inbox-tasks project-tasks)))src/folio/tools.sglmodified
(folio query) (folio integrity) (folio migrate) (folio frontmatter)) (folio frontmatter) (folio filecache)) (export register-folio-tools! never-silent update-task-in-store! find-all-tasks-by-id make-tool-task-move-batch move-task-in-store! integrity-warning-for-file) integrity-warning-for-file task-notes-text) (begin ;; ============================================================ ;; comparison (a predicate on both sides made the withdrawn attempt's ;; check blind by construction). (define (integrity-warning-for-file file-path label) (map (lambda (occ) (format " ~a line ~a: {id: ~a} is on disk but not visible as a task" label (car occ) (cdr occ))) (file-cache-ref integrity-cache file-path buried-occurrences))) ;; The scan result depends only on the file's bytes, so it is reused ;; while the file's stat signature is unchanged; see (folio filecache). (define integrity-cache (make-file-cache)) (define (buried-occurrences file-path) (if (not (file-exists? file-path)) '() (let* ((content (read-file-string file-path)) (claimed (map (lambda (b) (cons (+ 1 (task-block-start b)) (+ 1 (task-block-end b)))) blocks))) (map (lambda (occ) (format " ~a line ~a: {id: ~a} is on disk but not visible as a task" label (car occ) (cdr occ))) (buried-task-ids content visible claimed))))) (buried-task-ids content visible claimed)))) (define (make-tool-inbox store) (lambda (args) ;; Integrity lines across everything a mutation can reach. (define (store-integrity-lines store) (apply append (map (lambda (f) (integrity-warning-for-file f (path-basename f))) (cons (store-inbox-path store) (store-project-files store))))) (let ((files (store-project-files store))) (file-cache-prune! integrity-cache (store-project-dir store) files) (apply append (map (lambda (f) (integrity-warning-for-file f (path-basename f))) (cons (store-inbox-path store) files))))) (define (make-tool-status store idx) (lambda (args) (write-file-atomic file-path (string-join new-lines "\n")))))))) ;; Find all notes that reference a given task ID. ;; task-notes parsed all 4,467 notes on every call (6.5 s on the real ;; corpus). It needs only each note's name and frontmatter, so those are ;; kept per file and reused while the file's stat signature is unchanged ;; (see (folio filecache)). The answer is still computed from the files ;; on disk at call time: a rename, an outside edit or a moved task is ;; seen at the next call, with no index to keep in step. (define note-header-cache (make-file-cache)) ;; Only the frontmatter is cached: it depends on the bytes alone, while ;; the note's name depends on which store's notes/ it is read under. (define (read-note-frontmatter path) (folio-note-frontmatter (read-note path))) (define (find-notes-for-task store task-id) (filter (lambda (note) (member task-id (note-tasks note))) (all-notes store))) (let ((notes-dir (store-notes-dir store)) (files (store-note-files store))) (file-cache-prune! note-header-cache notes-dir files) (filter (lambda (note) (member task-id (note-tasks note))) (map (lambda (path) (folio-note name: (note-relative-name path notes-dir) file-path: path frontmatter: (file-cache-ref note-header-cache path read-note-frontmatter) body: "")) files)))) (define (make-tool-task-notes store) (lambda (args) (let* ((task-id (require-arg args id: "id")) (notes (find-notes-for-task store task-id))) (if (null? notes) (format "No notes linked to task ~a." task-id) (string-append (format "Notes linked to ~a (~a):\n\n" task-id (length notes)) (string-join (map (lambda (n) (let ((tags (note-tags n))) (string-append (format " ~a" (folio-note-name n)) (if (null? tags) "" (format " [~a]" (format-string-list tags ", ")))))) notes) "\n")))))) (task-notes-text store (require-arg args id: "id")))) ;;; The folio/task-notes reply for one task id. Exported so the parity ;;; check can compare it with a full parse of every note. (define (task-notes-text store task-id) (let ((notes (find-notes-for-task store task-id))) (if (null? notes) (format "No notes linked to task ~a." task-id) (string-append (format "Notes linked to ~a (~a):\n\n" task-id (length notes)) (string-join (map (lambda (n) (let ((tags (note-tags n))) (string-append (format " ~a" (folio-note-name n)) (if (null? tags) "" (format " [~a]" (format-string-list tags ", ")))))) notes) "\n"))))) (define (make-tool-note-create store idx) (lambda (args)test/test-census-bound.mjsadded
// The census fast path must not delay outside edits: once a server is idle// (the fast path armed), an outside change must reach backlinks within one// 15 s tick plus a 2 s margin. Each edit below keeps the file's size, and two// of them also keep its mtime, so only ctime, inode or a directory time moves.import assert from 'node:assert/strict';import { spawn, execFileSync } from 'node:child_process';import { mkdtemp, mkdir, writeFile, rm, stat, utimes, rename } from 'node:fs/promises';import { tmpdir } from 'node:os';import { join, resolve } from 'node:path';import { once } from 'node:events';import test from 'node:test';const binary = resolve(process.env.FOLIO_TEST_BINARY || 'build/dev/bin/folio');const pause = ms => new Promise(r => setTimeout(r, ms));const BOUND_MS = 17000;function client(root) { const child = spawn(binary, ['serve', '--stdio', '--log-level', 'debug'], { env: { ...process.env, FOLIO_ROOT: root }, stdio: ['pipe', 'pipe', 'pipe'], }); let sequence = 0, buffer = '', diagnostic = ''; const pending = new Map(); child.stderr.on('data', d => { diagnostic = (diagnostic + d).slice(-4000); }); child.stdout.on('data', d => { buffer += d; for (let end; (end = buffer.indexOf('\n')) >= 0;) { const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); let m; try { m = JSON.parse(line); } catch { continue; } const p = pending.get(m.id); if (!p) continue; pending.delete(m.id); if (m.error || m.result?.isError) p.reject(new Error(JSON.stringify(m))); else p.resolve(m.result); } }); return { child, call(name, args = {}) { const id = ++sequence; return new Promise((res, rej) => { pending.set(id, { resolve: res, reject: rej }); child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name: `folio/${name}`, arguments: args } }) + '\n'); }).then(r => r.content.map(c => c.text || '').join('\n')); }, async close() { const exited = once(child, 'exit'); child.stdin.end(); const timer = setTimeout(() => child.kill('SIGKILL'), 3000); await exited; clearTimeout(timer); }, diagnostic: () => diagnostic, };}// Poll note-links until `predicate` holds; return the elapsed ms or fail at the bound.async function within(c, name, predicate, label) { const start = performance.now(); for (;;) { if (predicate(await c.call('note-links', { name }))) return performance.now() - start; const elapsed = performance.now() - start; assert.ok(elapsed < BOUND_MS, `${label}: not visible after ${Math.round(elapsed)} ms`); await pause(200); }}const linked = '# Source\n[[target]]\n';const unlinked = '# Source\n[[zzzzzz]]\n';test('outside edits reach backlinks within one census tick of an idle server', { timeout: 120000 }, async t => { const root = await mkdtemp(join(tmpdir(), 'folio-census-bound-')); t.after(() => rm(root, { recursive: true, force: true })); await mkdir(join(root, 'notes')); await mkdir(join(root, 'projects')); await writeFile(join(root, 'notes/target.md'), '# Target\n'); const source = join(root, 'notes/source.md'); await writeFile(source, unlinked); const git = (...args) => execFileSync('git', ['-C', root, '-c', 'user.name=t', '-c', 'user.email=t@t', ...args]); git('init', '-q'); git('add', '-A'); git('commit', '-qm', 'unlinked'); // Let the files settle (the 2 s racily-clean window) so the startup census // arms the fast path and the first 15 s tick can skip. await pause(2500); const c = client(root); t.after(() => c.close()); assert.doesNotMatch(await c.call('note-links', { name: 'target' }), /← source/); // Let at least one idle tick pass, so the fast path is what sees the edits. await pause(16000); assert.match(c.diagnostic(), /Census tick.*unchanged/, 'the fast path must be armed before the edits'); const has = s => /← source \(note\)/.test(s); const results = {}; // 1. Same size, same second, mtime restored: only ctime moves. const before = await stat(source); await writeFile(source, linked); await utimes(source, before.atime, before.mtime); results.restoredMtime = await within(c, 'target', has, 'same-size edit with restored mtime'); // 2. git checkout of the equal-size committed revision (a replacement). await pause(1000); git('checkout', '-q', '--', 'notes/source.md'); results.gitCheckout = await within(c, 'target', s => !has(s), 'git checkout'); // 3. Rename over the file with a same-size, same-mtime copy (new inode). await pause(1000); const staged = join(root, 'staged.md'); const now = await stat(source); await writeFile(staged, linked); await utimes(staged, now.atime, now.mtime); await rename(staged, source); results.renameOver = await within(c, 'target', has, 'rename-over replacement'); // 4. A new linking note in a new directory. await mkdir(join(root, 'notes/deep')); await writeFile(join(root, 'notes/deep/added.md'), '# Added\n[[target]]\n'); results.added = await within(c, 'target', s => /← deep\/added \(note\)/.test(s), 'added note'); console.log(JSON.stringify({ step: 'census-bound', boundMs: BOUND_MS, visibleMs: Object.fromEntries(Object.entries(results).map(([k, v]) => [k, Math.round(v)])) }));});test/test-idle-census.sgladded
;;; The 0.3.2 idle work: the census fast path, its yields, the parse cache;;; behind read-only tools, and task-notes answered from cached frontmatter.;;;;;; Every "outside edit" case writes a file the way a non-folio writer would;;; and checks the result against an independent full parse, so a fast path;;; that skips a real change fails here.;;;;;; Most tests set both settle windows to 0, so a file written a moment ago;;; can be trusted at once, and wait `granule` before each outside edit:;;; Linux stamps files from a coarse clock, and two writes inside one tick of;;; it can be indistinguishable (that is what the settle window guards; its;;; own tests run with it on).(import (sigil test) (sigil async) (sigil fs) (sigil path) (sigil sqlite) (sigil string) (sigil process) (sigil list) (sigil mcp server) (sigil mcp protocol) (folio store) (folio note) (folio links) (folio project) (folio task) (folio query) (folio index) (folio filecache) (folio tools))(census-settle-seconds 0)(file-cache-settle-seconds 0)(define (sh . args) (assert-equal 0 (apply process-run args)))(define (granule) (sleep 0.05))(define (call-tool server name args) (response->json (mcp-server-handle-message server (jsonrpc-request id: 1 method: "tools/call" params: (dict name: name arguments: args)))))(define (note-text body) (string-append "---\ntags: []\n---\n\n" body "\n"));; Two bodies of equal length: rewriting one with the other keeps the size.(define linked "# Source\n[[target]]")(define unlinked "# Source\n[[zzzzzz]]")(define (backlinks-match? store idx name) (equal? (find-all-backlinks store name) (index-backlinks idx name)))(define (with-census-store thunk) (call-with-temp-directory (lambda (root) (let* ((store (make-folio-store root)) (_ (store-ensure-dirs! store)) (notes (store-notes-dir store)) (source (path-join notes "source.md"))) (write-file-string (path-join notes "target.md") (note-text "# Target")) (write-file-string source (note-text unlinked)) (let ((idx (make-folio-index store))) (thunk root store idx source) (sqlite-close (folio-index-db idx)))))))(define (with-settle seconds thunk) (census-settle-seconds seconds) (file-cache-settle-seconds seconds) (guard (e (else (census-settle-seconds 0) (file-cache-settle-seconds 0) (raise e))) (thunk) (census-settle-seconds 0) (file-cache-settle-seconds 0)))(test-group "census fast path" (test "an unchanged corpus skips the per-file census" (with-census-store (lambda (root store idx source) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal 'unchanged (index-census-tick! idx)) (assert-equal 'unchanged (index-census-tick! idx))))) (test "a same-second, same-size outside edit is reconciled" (with-census-store (lambda (root store idx source) (index-census-tick! idx) (assert-equal 'unchanged (index-census-tick! idx)) (assert-equal '() (index-backlinks idx "target")) (granule) (write-file-string source (note-text linked)) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal '(("source" . note)) (index-backlinks idx "target")) (assert-true (backlinks-match? store idx "target"))))) (test "an edit whose mtime is restored with touch is reconciled (ctime moves)" (with-census-store (lambda (root store idx source) (let ((stamp (path-join root "stamp"))) (index-census-tick! idx) (assert-equal 'unchanged (index-census-tick! idx)) (write-file-string stamp "") (sh "touch" "-r" source stamp) (granule) (write-file-string source (note-text linked)) (sh "touch" "-r" stamp source) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal '(("source" . note)) (index-backlinks idx "target")))))) (test "a replacement with the same size and mtime is reconciled (rename over)" (with-census-store (lambda (root store idx source) (let ((staged (path-join root "staged.md"))) (index-census-tick! idx) (assert-equal 'unchanged (index-census-tick! idx)) (granule) (write-file-string staged (note-text linked)) (sh "touch" "-r" source staged) (sh "mv" staged source) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal '(("source" . note)) (index-backlinks idx "target")))))) (test "a git checkout of an equal-size revision is reconciled" (with-census-store (lambda (root store idx source) (let ((git (lambda args (apply sh "git" "-C" root "-c" "user.name=t" "-c" "user.email=t@t" args)))) (git "init" "-q") (write-file-string source (note-text linked)) (git "add" "-A") (git "commit" "-qm" "linked") (write-file-string source (note-text unlinked)) (git "commit" "-qam" "unlinked") (index-census-tick! idx) (assert-equal 'unchanged (index-census-tick! idx)) (assert-equal '() (index-backlinks idx "target")) (granule) (git "checkout" "-q" "HEAD~1" "--" "notes/source.md") (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal '(("source" . note)) (index-backlinks idx "target")))))) (test "rows another connection deletes are restored by the census it forces" (with-census-store (lambda (root store idx source) (write-file-string source (note-text linked)) (index-census-tick! idx) (assert-equal 'unchanged (index-census-tick! idx)) (assert-equal '(("source" . note)) (index-backlinks idx "target")) (let ((other (sqlite-open (path-join root ".folio-index.db")))) (assert-true (sqlite-run other "DELETE FROM note_links WHERE source = ?" source)) (assert-true (sqlite-run other "DELETE FROM link_sources WHERE path = ?" source)) (sqlite-close other)) (assert-equal '() (index-backlinks idx "target")) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal '(("source" . note)) (index-backlinks idx "target")) (assert-equal 'unchanged (index-census-tick! idx))))) (test "an editor's dangling lock link keeps the fast path; a note symlink disables it" (with-census-store (lambda (root store idx source) (let ((notes (store-notes-dir store)) (outside (path-join root "outside.md"))) (sh "ln" "-s" "user@host.1234:1700000000" (path-join notes ".#source.md")) (index-census-tick! idx) (assert-equal 'unchanged (index-census-tick! idx)) (write-file-string outside (note-text unlinked)) (sh "ln" "-s" outside (path-join notes "linked.md")) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal 'reconciled (index-census-tick! idx)) ;; The walk follows the link, so its target's edit must be seen. (granule) (write-file-string outside (note-text linked)) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal '(("linked" . note)) (index-backlinks idx "target")))))) (test "a symlink whose name holds a newline cannot pass as a hidden entry" (with-census-store (lambda (root store idx source) (let ((outside (path-join root "outside.md"))) (write-file-string outside (note-text unlinked)) (sh "ln" "-s" outside (path-join (store-notes-dir store) "x\n.y.md")) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal 'reconciled (index-census-tick! idx)))))) (test "a failure deferred until the file changes still arms the fast path" (with-census-store (lambda (root store idx source) (index-census-tick! idx) (granule) (write-file-string source (note-text linked)) (sh "chmod" "000" source) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal 'unchanged (index-census-tick! idx)) (granule) (sh "chmod" "600" source) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal '(("source" . note)) (index-backlinks idx "target")) (assert-equal 'unchanged (index-census-tick! idx))))) (test "a retryable failure (database busy) keeps every tick on the full census" (with-census-store (lambda (root store idx source) (index-census-tick! idx) (assert-equal 'unchanged (index-census-tick! idx)) (let ((budget (sqlite-busy-budget)) (other (sqlite-open (path-join root ".folio-index.db")))) (sqlite-set-busy-budget! 0.2) (granule) (write-file-string source (note-text linked)) (assert-true (sqlite-exec other "BEGIN IMMEDIATE")) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal '() (index-backlinks idx "target")) (assert-equal 'reconciled (index-census-tick! idx)) (assert-true (sqlite-exec other "ROLLBACK")) (sqlite-close other) (sqlite-set-busy-budget! budget) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal '(("source" . note)) (index-backlinks idx "target")) (assert-equal 'unchanged (index-census-tick! idx)))))) (test "an unsettled listing is not trusted until its newest time is old enough" (with-settle 1 (lambda () (with-census-store (lambda (root store idx source) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal 'reconciled (index-census-tick! idx)) (sleep 1.2) (assert-equal 'reconciled (index-census-tick! idx)) (assert-equal 'unchanged (index-census-tick! idx))))))) (test "a full census with no per-file work still yields to other tasks" (call-with-temp-directory (lambda (root) (let* ((store (make-folio-store root)) (_ (store-ensure-dirs! store)) (idx (make-folio-index store)) (done #f) (turns 0)) (let loop ((i 0)) (when (< i 200) (write-file-string (path-join (store-notes-dir store) (format "n~a.md" i)) (note-text (format "# N~a\n[[n0]]" i))) (loop (+ i 1)))) (index-census-tick! idx) (let ((second (index-census-tick! idx))) (display (format "yield test: second tick=~a\n" second)) (assert-equal 'unchanged second)) ;; Another connection commits: the next tick runs the full census, ;; but every file is current, so the pacer's yields are the only ones. (let ((other (sqlite-open (path-join root ".folio-index.db")))) (assert-true (sqlite-run other "UPDATE notes_meta SET indexed_at = indexed_at + 1")) (sqlite-close other)) (with-async (go (set! done (index-census-tick! idx))) (go (let loop () (unless done (set! turns (+ turns 1)) (yield) (loop))))) (display (format "yield test: tick=~a turns=~a\n" done turns)) (assert-equal 'reconciled done) ;; 200 files with a yield every 32: at least 6 turns for the other task. (assert-true (>= turns 6)) (assert-true (backlinks-match? store idx "n0")) (sqlite-close (folio-index-db idx)))))))(test-group "parse cache" (test "an unchanged file is computed once and the value reused" (call-with-temp-directory (lambda (root) (let ((cache (make-file-cache)) (path (path-join root "f.md")) (calls 0)) (write-file-string path "one") (let* ((compute (lambda (p) (set! calls (+ calls 1)) (read-file-string p))) (a (file-cache-ref cache path compute)) (b (file-cache-ref cache path compute))) (assert-equal "one" a) (assert-true (eq? a b)) (assert-equal 1 calls) (assert-equal 1 (file-cache-size cache))))))) (test "same-second same-size write, restored mtime, rename-over and git checkout all recompute" (call-with-temp-directory (lambda (root) (let* ((cache (make-file-cache)) (path (path-join root "f.md")) (stamp (path-join root "stamp")) (staged (path-join root "staged")) (read (lambda () (file-cache-ref cache path read-file-string))) (git (lambda args (apply sh "git" "-C" root "-c" "user.name=t" "-c" "user.email=t@t" args)))) (write-file-string path "aaaa") (assert-equal "aaaa" (read)) ;; Same second, same size, in place. (granule) (write-file-string path "bbbb") (assert-equal "bbbb" (read)) ;; Same size, mtime put back: ctime still moves. (write-file-string stamp "") (sh "touch" "-r" path stamp) (granule) (write-file-string path "cccc") (sh "touch" "-r" stamp path) (assert-equal "cccc" (read)) ;; Replaced by a file with the same size and mtime: a new inode. (granule) (write-file-string staged "dddd") (sh "touch" "-r" path staged) (sh "mv" staged path) (assert-equal "dddd" (read)) ;; git checkout of an equal-size revision. (git "init" "-q") (git "add" "f.md") (git "commit" "-qm" "d") (granule) (write-file-string path "eeee") (assert-equal "eeee" (read)) (granule) (git "checkout" "-q" "--" "f.md") (assert-equal "dddd" (read)))))) (test "a recently written file is not cached until it settles" (with-settle 1 (lambda () (call-with-temp-directory (lambda (root) (let* ((cache (make-file-cache)) (path (path-join root "f.md")) (calls 0) (read (lambda () (file-cache-ref cache path (lambda (p) (set! calls (+ calls 1)) (read-file-string p)))))) (write-file-string path "fresh") (read) (read) (assert-equal 2 calls) (assert-equal 0 (file-cache-size cache)) (sleep 1.2) (read) (read) (assert-equal 3 calls) (assert-equal 1 (file-cache-size cache)))))))) (test "a compute that rewrites the file or raises caches nothing; prune drops vanished paths" (call-with-temp-directory (lambda (root) (let ((cache (make-file-cache)) (path (path-join root "f.md")) (other (path-join root "g.md"))) (write-file-string path "1111") (granule) (assert-equal "1111" (file-cache-ref cache path (lambda (p) (let ((v (read-file-string p))) (write-file-string p "2222") v)))) (assert-equal 0 (file-cache-size cache)) (assert-equal "2222" (file-cache-ref cache path read-file-string)) (assert-true (guard (e (else #t)) (file-cache-ref cache other (lambda (p) (error "boom"))) #f)) (write-file-string other "g") (assert-equal "g" (file-cache-ref cache other read-file-string)) (assert-equal 2 (file-cache-size cache)) (file-cache-prune! cache root (list path)) (assert-equal 1 (file-cache-size cache)))))) (test "tasks, projects and status follow equal-size outside edits of a project file" (call-with-temp-directory (lambda (root) (let* ((store (make-folio-store root)) (_ (store-ensure-dirs! store)) (project (path-join (store-project-dir store) "p.md")) (body (lambda (word status) (format "---\nstatus: ~a\n---\n\n# P\n\n## Tasks\n\n- [ ] ~a {id: t-1111}\n" status word))) (texts (lambda () (map folio-task-text (all-tasks store))))) (write-file-string project (body "alpha" "active")) (assert-equal '("alpha") (texts)) (granule) (write-file-string project (body "bravo" "active")) (assert-equal '("bravo") (texts)) (assert-equal '("active") (map project-status (all-projects store))) (granule) (write-file-string project (body "bravo" "paused")) (assert-equal '("paused") (map project-status (all-projects store))) (assert-equal 0 (dict-ref (folio-status store) active-project-count:)))))));; The reply task-notes gave before 0.3.2: a full parse of every note.(define (reference-task-notes store task-id) (let ((notes (filter (lambda (n) (member task-id (note-tasks n))) (all-notes store)))) (if (null? notes) (format "No notes linked to task ~a." task-id) (string-append (format "Notes linked to ~a (~a):\n\n" task-id (length notes)) (string-join (map (lambda (n) (let ((tags (note-tags n))) (string-append (format " ~a" (folio-note-name n)) (if (null? tags) "" (format " [~a]" (string-join tags ", ")))))) notes) "\n")))))(define (assert-task-notes-parity store ids) (for-each (lambda (id) (assert-equal (reference-task-notes store id) (task-notes-text store id))) ids))(test-group "task-notes from cached frontmatter" (test "matches a full parse across create, outside edits, rename, task move and delete" (call-with-temp-directory (lambda (root) (let* ((store (make-folio-store root)) (_ (store-ensure-dirs! store)) (idx (make-folio-index store)) (server (mcp-server name: "task-notes-test")) (ids '("t-aaaa" "t-bbbb" "t-cccc" "t-none" "42")) (notes-dir (store-notes-dir store)) (project (path-join (store-project-dir store) "p.md"))) (register-folio-tools! server store idx) (write-file-string project "---\nstatus: active\n---\n\n# P\n\n## Tasks\n\n- [ ] A {id: t-aaaa}\n- [ ] B {id: t-bbbb}\n") (write-file-string (path-join (store-project-dir store) "q.md") "---\nstatus: active\n---\n\n# Q\n\n## Tasks\n\n") (note-create! store "brief-a" '("brief") "About A." '("t-aaaa")) (note-create! store "nested/both" '("brief" "x") "Both." '("t-aaaa" "t-bbbb")) (note-create! store "plain" '() "No tasks.") ;; Malformed tasks: values, handled the same way by both answers. (write-file-string (path-join notes-dir "scalar.md") "---\ntasks: t-aaaa\n---\n\nScalar.\n") (write-file-string (path-join notes-dir "number.md") "---\ntasks: 42\n---\n\nNumber.\n") (write-file-string (path-join notes-dir "nested.md") "---\ntasks:\n - [t-bbbb]\n - t-cccc\n---\n\nNested.\n") (write-file-string (path-join notes-dir "empty.md") "---\ntasks:\n---\n\nEmpty.\n") (write-file-string (path-join notes-dir "odd-tags.md") "---\ntags: [7, x]\ntasks: [t-aaaa]\n---\n\nOdd tags.\n") (assert-task-notes-parity store ids) ;; Outside edit: same-size swap of a task id in the frontmatter. (let ((path (path-join notes-dir "brief-a.md"))) (granule) (write-file-string path "---\ntags: [brief]\ntasks: [t-cccc]\n---\n\nAbout A.\n") (assert-task-notes-parity store ids) (granule) (write-file-string path "---\ntags: [brief]\ntasks: [t-bbbb]\n---\n\nAbout A.\n") (assert-task-notes-parity store ids)) ;; Rename through the tool, and an outside rename. (assert-true (string-contains? (call-tool server "folio/note-rename" (dict name: "nested/both" new-name: "renamed/both")) "Renamed")) (assert-task-notes-parity store ids) (sh "mv" (path-join notes-dir "plain.md") (path-join notes-dir "plain2.md")) (assert-task-notes-parity store ids) ;; Moving a task between projects keeps its id and its notes. (assert-true (string-contains? (call-tool server "folio/task-move" (dict id: "t-aaaa" project: "q")) "Moved task")) (assert-task-notes-parity store ids) (assert-true (string-contains? (task-notes-text store "t-aaaa") "renamed/both")) ;; Deleting a note drops it. (delete-file (path-join notes-dir "brief-a.md")) (assert-task-notes-parity store ids) (sqlite-close (folio-index-db idx)))))) (test "the same file is named by the store that reads it" (call-with-temp-directory (lambda (root) (let* ((outer (make-folio-store root)) (_ (store-ensure-dirs! outer)) (inner-root (path-join (store-notes-dir outer) "inner")) (_ (ensure-directory inner-root)) (inner (make-folio-store inner-root))) (store-ensure-dirs! inner) (write-file-string (path-join (store-notes-dir inner) "n.md") "---\ntasks: [t-dddd]\n---\n\nInner.\n") (assert-true (string-contains? (task-notes-text outer "t-dddd") " inner/notes/n")) (assert-true (string-contains? (task-notes-text inner "t-dddd") " n")) (assert-false (string-contains? (task-notes-text inner "t-dddd") "inner/notes/n")))))))tools/check-task-notes-parity.sgladded
;;; Differential check of folio/task-notes on a DISPOSABLE copy of a real corpus.;;;;;; The reference is the pre-0.3.2 algorithm: parse every note, keep those whose;;; frontmatter `tasks:` holds the id. Every id any note mentions, a sample of;;; project task ids and some ids nobody uses are compared after each of: no;;; change, an outside frontmatter edit, an outside note move, a note-rename;;; through the tool, a task-move through the tool, and a deletion.(import (sigil fs) (sigil list) (sigil path) (sigil string) (sigil time) (sigil sqlite) (sigil mcp server) (sigil mcp protocol) (folio store) (folio note) (folio task) (folio query) (folio index) (folio tools))(define root (getenv "FOLIO_CORPUS"))(unless root (error "FOLIO_CORPUS must name a disposable corpus copy"))(when (string-contains? root "/Ops/folio") (error "refusing to run against a live store"))(define store (make-folio-store root))(define idx (make-folio-index store))(define server (mcp-server name: "task-notes-parity"))(register-folio-tools! server store idx)(define (wall-ms) (* 1000.0 (/ (current-jiffy) (jiffies-per-second))))(define (tool name args) (response->json (mcp-server-handle-message server (jsonrpc-request id: 1 method: "tools/call" params: (dict name: name arguments: args)))))(define (reference-text notes task-id) (let ((hits (filter (lambda (n) (member task-id (note-tasks n))) notes))) (if (null? hits) (format "No notes linked to task ~a." task-id) (string-append (format "Notes linked to ~a (~a):\n\n" task-id (length hits)) (string-join (map (lambda (n) (let ((tags (note-tags n))) (string-append (format " ~a" (folio-note-name n)) (if (null? tags) "" (format " [~a]" (string-join tags ", ")))))) hits) "\n")))))(define (unique strings) (let loop ((rest strings) (seen (dict)) (out '())) (cond ((null? rest) (reverse out)) ((dict-contains? seen (car rest)) (loop (cdr rest) seen out)) (else (loop (cdr rest) (dict-set seen (car rest) #t) (cons (car rest) out))))))(define (take-up-to lst n) (if (or (null? lst) (= n 0)) '() (cons (car lst) (take-up-to (cdr lst) (- n 1)))))(define (ids-now notes) (unique (append (apply append (map note-tasks notes)) (take-up-to (filter (lambda (x) x) (map folio-task-id (all-tasks store))) 60) '("t-0000" "t-ffffff" "nonexistent" ""))))(define (round! label) (let* ((t0 (wall-ms)) (notes (all-notes store)) (ref-ms (- (wall-ms) t0)) (ids (ids-now notes)) (linked 0)) (for-each (lambda (id) (let ((expected (reference-text notes id)) (actual (task-notes-text store id))) (unless (equal? expected actual) (error "task-notes differs from the full parse" label id expected actual)) (unless (string-starts-with? actual "No notes") (set! linked (+ linked 1))))) ids) (let* ((t1 (wall-ms)) (_ (task-notes-text store (car ids))) (one-ms (- (wall-ms) t1))) (display (format "PARITY ~a ids=~a linked_ids=~a reference_parse_ms=~a cached_call_ms=~a\n" label (length ids) linked (round ref-ms) (round one-ms)))) ids));; First call from cold (fills the cache) against the old cost.(let* ((t0 (wall-ms)) (_ (task-notes-text store "t-0000"))) (display (format "COLD first_call_ms=~a\n" (round (- (wall-ms) t0)))))(round! "initial");; A note that links tasks, for the mutations.(define linking (let ((notes (filter (lambda (n) (and (pair? (note-tasks n)) (string-starts-with? (car (note-tasks n)) "t-"))) (all-notes store)))) (when (null? notes) (error "corpus has no task-linked notes")) (car notes)))(define linking-name (folio-note-name linking))(define linking-task (car (note-tasks linking)))(display (format "MUTATE note=~a task=~a\n" linking-name linking-task));; Outside edit: add a task id to a different, unlinked note's frontmatter.(define plain (car (filter (lambda (n) (null? (note-tasks n))) (all-notes store))))(write-file-string (folio-note-file-path plain) (string-append "---\ntasks:\n - " linking-task "\n---\n\n" (folio-note-body plain)))(round! "outside-edit");; Outside move of the linking note into a new directory.(define moved-path (path-join (store-notes-dir store) "parity-moved" "moved-note.md"))(ensure-directory (path-join (store-notes-dir store) "parity-moved"))(rename-file (folio-note-file-path linking) moved-path)(round! "outside-move");; Rename through the tool.(let ((reply (tool "folio/note-rename" (dict name: "parity-moved/moved-note" new-name: "parity-moved/renamed-note")))) (unless (string-contains? reply "Renamed") (error "note-rename failed" reply)))(round! "tool-rename");; Move a linked task to another project through the tool (when it is a project task).(let* ((task (find (lambda (t) (equal? (folio-task-id t) linking-task)) (all-tasks store))) (projects (store-project-files store)) (target (and task (find (lambda (p) (not (equal? p (folio-task-source-file task)))) projects)))) (if (and task target) (let ((reply (tool "folio/task-move" (dict id: linking-task project: (path-stem (path-basename target)))))) (unless (string-contains? reply "Moved task") (error "task-move failed" reply)) (display (format "MOVED ~a to ~a\n" linking-task (path-basename target)))) (display (format "MOVED skipped: ~a is not a project task here\n" linking-task))))(round! "tool-task-move");; Deletion.(delete-file (path-join (store-notes-dir store) "parity-moved" "renamed-note.md"))(round! "delete")(sqlite-close (folio-index-db idx))(display "TASK-NOTES PARITY PASSED\n")