diff --git a/.gitignore b/.gitignore index c2c113f..b08128a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ __pycache__ dist *.log .DS_Store +knowledge/staged/*.md .letta/ diff --git a/Dockerfile b/Dockerfile index ec5e0b5..80650d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ RUN npm install --omit=dev # Copy app source COPY src/ src/ COPY public/ public/ +COPY knowledge/published/ knowledge/published/ COPY tsconfig.json ./ ENV PORT=8080 diff --git a/README.md b/README.md index ceef7a7..8e7fc2d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,46 @@ pnpm publish This runs `scripts/publish.ts`, which reads markdown files and pushes them as AT Protocol records. +## Public knowledge + +Public Knowledge is a file-backed, linked knowledge base at `/knowledge`. It +opens on a public NOW note and includes Entries, Semble Links, and Margin +Annotations. Private Obsidian notes never enter the production build directly. + +Canonical architecture and operations: [`docs/public-knowledge.md`](docs/public-knowledge.md). + +```bash +# Convert an allowlisted Coil note into an ignored local draft. +pnpm knowledge:stage "$HOME/Documents/The Coil/lessons/example.md" \ + --summary "Public summary" \ + --source "Primary source=https://example.com" + +# Validate staged and published entries. +pnpm knowledge:check -- --include-drafts + +# Preview ignored drafts locally. Production never enables this flag. +KNOWLEDGE_INCLUDE_DRAFTS=1 pnpm dev + +# After Cameron reviews the exact rendered draft and supplies a receipt. +pnpm knowledge:promote example \ + --reviewed-by Cameron \ + --content-digest sha256:reviewed-file-digest \ + --review-receipt-digest sha256:private-receipt-digest \ + --confirm-public + +# Preview the ATProto/Leaflet records without writing them. +pnpm knowledge:sync -- --include-drafts --json + +# Reconcile approved entries only. Requires Cameron's PDS credentials. +pnpm knowledge:sync -- --apply +``` + +`knowledge/policy.json` is default-deny and owns the Obsidian source mappings. +Draft Markdown is gitignored under `knowledge/staged/`. The Docker image copies +only `knowledge/published/`, so a local draft cannot leak through a broad build +context. The public file stores only a digest of the private route-scoped review +receipt. Promotion and deployment are separate actions. + ## Deployment Deployed to Fly.io as `cameron-stream`: @@ -90,4 +130,7 @@ public/ host-primitives.css host-theme.css theme.js -- Dark/light mode toggle +knowledge/ + policy.json -- Default-deny source mappings and privacy rules + published/ -- Explicitly reviewed entries included in production ``` diff --git a/docs/public-knowledge.md b/docs/public-knowledge.md new file mode 100644 index 0000000..9c422a7 --- /dev/null +++ b/docs/public-knowledge.md @@ -0,0 +1,313 @@ +# Public Knowledge + +Canonical design and operations note for Cameron's public knowledge base. + +## Purpose + +Public Knowledge publishes a selected, linked, corrigible subset of what Cameron +learns through research, work, reading, and collaboration. It opens on NOW, a +public version of the daily note, then connects that live synthesis to durable +entries, saved links, and annotations. It is not an export of The Coil and not +an automatically generated biography. + +Every entry is AI-assisted. Factual claims need public sources. Cameron's views +are labeled as perspective. Inference and uncertainty stay visible. Readers get +a correction route. + +## Current status + +The local first slice exists in the integrated site worktree. Nothing in this +project has been promoted, committed, deployed, or written to Cameron's PDS yet. + +Implemented: + +- shared-theme `/knowledge` NOW page and `/knowledge/:slug` entry pages; +- unified NOW, Entries, Semble Links, and Margin Annotations sections; +- redirects from `/library`, `/semble`, and `/annotations` into Knowledge; +- search, topics, related links, backlinks, sources, confidence, and claim mode; +- draft-only preview mode with crawler exclusion; +- default-deny Obsidian source mappings; +- deterministic staging, checking, digest-bound promotion, and ATProto planning; +- a one-way `site.standard.document` reconciler using Leaflet blocks; +- four ignored review drafts: NOW, Public Knowledge, Cameron Pfiffer, and + Spec-Driven Development for AI Coding Agents; +- a skill-backed daily NOW schedule at 00:47 America/Los_Angeles, after the + private 00:08 consolidation. + +## Visual contract + +Knowledge pages are ordinary site pages: + +- the shared 640px content shell; +- Around for display headings; +- Inter for body text; +- the same navigation, colors, theme toggle, and responsive rules; +- dense indexes and backlinks rather than a decorative graph as the primary UI. + +Knowledge replaces Library in the primary navigation. Saved links and +annotations remain the same public records; they now sit beside the notes they +inform. + +Document class is explicit in the PDS. Main publication posts carry the +lowercase `blog` tag and the blog index requires it. Knowledge records carry +`knowledge` plus their entry kind, claim mode, and topics. Sharing one +`site.standard.document` collection therefore does not make every document a +blog post. + +This keeps Knowledge inside Cameron's site instead of creating a second visual +identity for the same person. + +## Storage and authority + +The pipeline has four distinct surfaces: + +1. **Private source**: allowlisted Obsidian notes in The Coil. +2. **Staged draft**: ignored Markdown under `knowledge/staged/`; local preview only. +3. **Approved editorial source**: reviewed Markdown under `knowledge/published/`. +4. **Public distribution record**: `site.standard.document` on Cameron's PDS. + +Approved Markdown is the editorial source of truth. The PDS record is the public, +portable distribution record. Remote edits are treated as conflicts rather than +silently imported. Bidirectional editing is deliberately out of scope for the +first version. + +Production has two physical exclusions: + +- `knowledge/staged/*.md` is gitignored; +- the Docker image copies only `knowledge/published/`. + +A private draft therefore cannot become live through a broad build context. + +## Entry contract + +Required metadata: + +| Field | Meaning | +|---|---| +| `title`, `slug`, `summary` | Public identity and index copy | +| `kind` | concept, journal, lesson, organization, person, place, practice, or project | +| `status` | active, evolving, or historical | +| `claimMode` | factual, perspective, or mixed | +| `perspectiveOwner` | Required for perspective and mixed entries | +| `confidence` | high, medium, or low | +| `topics` | Public topic labels | +| `related` | Slugs of other public knowledge entries | +| `sources` | Public URLs supporting factual claims | +| `aiAssisted`, `generatedBy` | Visible generation provenance | +| `sourceDigest` | Hash of the private source without exposing its path | +| `updated` | Editorial update time | +| `reviewStatus` | draft or approved | + +Approved entries additionally require: + +- `reviewedBy` and `reviewedAt`; +- `publishedAt`, preserved across updates; +- `reviewedContentDigest`, binding approval to the exact staged file; +- `reviewReceiptDigest`, an opaque hash of the private route-scoped approval + receipt. Channel IDs and message IDs do not enter the public repository. + +## Declassification gate + +`knowledge/policy.json` is default-deny. A source mapping grants permission to +stage and inspect a note, not to publish it. + +The gate evaluates independent dimensions: + +### Source class + +- allowlisted: selected lesson and technical concept folders; +- manual-only: exceptional sources requiring an explicit decision; +- denied: people, journal, consolidation, relationship, health, therapy, + housing, finance, and operations material. + +### Claim class + +- sourced fact; +- Cameron's perspective; +- explicit inference; +- uncertainty; +- unsupported claim, which is removed or returned for sourcing. + +### Sensitivity class + +- already public; +- low-sensitivity technical material; +- personal material requiring exact review; +- protected material denied by default. + +### People + +People are default-deny. `cameron-pfiffer` is the only currently allowlisted +person slug, because Cameron explicitly requested his own factual entry. + +Person entries must remain factual and publicly sourced. Future relationship +edges are limited to narrow predicates such as `works_at`, `studied_at`, +`created`, `contributed_to`, and `collaborated_with`. Private opinion, +relationship quality, motive, diagnosis, attraction, and inferred influence do +not belong in this system. + +## Review and promotion + +The converter is mechanical. It strips private frontmatter and Obsidian syntax, +but it is not a declassification authority. The complete staged file still gets +manual inspection and a privacy/claim check. + +Cameron reviews the exact rendered draft, including metadata, sources, +backlinks, and disclaimers. Approval of the project, topic, folder, or original +note is not approval of transformed copy. + +The checker prints a SHA-256 digest for each staged file. Promotion requires that +exact digest plus a digest of the private review receipt. Any content change +invalidates the approval. + +Promotion and publication remain separate actions. + +## Daily NOW + +NOW is a fresh public synthesis written after private consolidation. It does not +redact the private daily note line by line or preserve its private structure. + +The daily source set includes: + +- the private daily note and consolidation; +- Cameron's Bluesky posts and replies from the local day; +- new public knowledge entries and project receipts; +- Semble links and collections; +- Margin annotations; +- already-public talks, writing, code, and artifacts that became relevant. + +Bluesky activity is especially useful because it is evidence of what Cameron +already chose to make public. The pass still distinguishes a passing reply from +a durable belief. + +The output can include what was intellectually in view, what changed, public +projects or questions in motion, new public objects, and one live question. It +uses neutral or Co-attributed prose rather than ghostwriting Cameron's +first-person voice. + +The stable entry slug `now` maps to `knowledge-now`. Dated archive slugs use +`daily-YYYY-MM-DD` and map to `knowledge-daily-YYYY-MM-DD`. The stable record is +the current view; the dated record preserves the public journal entry. + +The 00:47 scheduled prompt is intentionally small: + +```text +Use the publishing-public-knowledge skill to create and publish today’s public NOW note after private consolidation. +``` + +The skill owns source retrieval, declassification, passing, record construction, +sync, and verification. + +## ATProto decision + +Knowledge reuses the existing `site.standard.document` ecosystem. A custom +knowledge lexicon would duplicate document, publication, indexing, and rendering +machinery that Cameron already owns. + +A custom `stream.cameron.knowledge.entry` content object was investigated and +would preserve exact knowledge metadata well, but current Leaflet would not know +how to render it. Cameron chose immediate Standard.site and Leaflet compatibility, +so the public record uses `pub.leaflet.content`; knowledge-specific metadata stays +visible in the compiled article and approved local source. + +Stable mapping: + +| Local object | ATProto object | +|---|---| +| slug `spec-driven-development-for-ai-coding-agents` | rkey `knowledge-spec-driven-development-for-ai-coding-agents` | +| site URL | `/knowledge/spec-driven-development-for-ai-coding-agents` | +| collection | `site.standard.document` | +| publication | Cameron's existing `site.standard.publication` | +| content | `pub.leaflet.content` with `pub.leaflet.pages.linearDocument` blocks | + +Leaflet resolves a document by rkey. The corresponding Leaflet URL is therefore +`cameron.leaflet.pub/knowledge-`, while Cameron's personal site uses +`/knowledge/`. Both resolve to the same ATProto record identity. + +Compatibility caveat: the current Standard.site document Lexicon declares TID +record keys, while Cameron's live repository already contains working slug-rkey +documents. `knowledge-` follows that deployed convention and Cameron's +explicit namespace decision, but a one-entry canary must record the PDS +`validationStatus`. If the PDS or an AppView stops tolerating slug rkeys, the +fallback is a TID rkey plus a local slug-to-URI manifest; the public path does not +need to change. + +The old `site.standard.document#markdown` content union is forbidden for new +knowledge records. Cameron's live PDS already demonstrates why: older Markdown +records remain intact but current Leaflet renders them as “post not found,” while +`pub.leaflet.content` block records render correctly. + +The compiler generates: + +- title, description, tags, stable publication time, and `/knowledge/` path; +- a deterministic linear-document page ID; +- Leaflet header, text, blockquote, list, table, and code-compatible blocks; +- UTF-8 byte-indexed link, bold, italic, and code facets; +- visible AI-assistance, claim mode, confidence, update date, and sources; +- `textContent` for public indexing. + +Knowledge-specific review digests remain in the approved local file and sync +manifest. They are not added as unknown fields to the closed standard-document +record. + +## Reconciler + +`scripts/sync-knowledge-atproto.ts` is a small one-way reconciler. + +Default mode is dry-run. `--include-drafts` is allowed only in dry-run so record +shape can be inspected before promotion. `--apply` reads approved entries only. + +For each approved entry it: + +1. resolves Cameron's current PDS endpoint from his DID document; +2. compiles the reviewed Markdown into a deterministic standard document; +3. reads `knowledge-` anonymously from the PDS; +4. compares the desired record digest, remote CID, and local manifest; +5. plans create, update, unchanged, adopt-identical, or conflict; +6. authenticates only in apply mode and verifies the session DID is Cameron's; +7. updates with `swapRecord` bound to the observed CID; +8. re-reads the record and verifies both CID and content digest; +9. writes the public sync receipt to `knowledge/atproto-manifest.json`. + +If the remote CID changed outside the reconciler, it stops. If an unmanaged +record already occupies the rkey with different content, it stops. If a local +published file disappears, it emits a drift warning and does not delete the PDS +record. Deletion requires a separate explicit operation. + +## Commands + +```bash +# Stage an allowlisted private note. This never publishes. +pnpm knowledge:stage "$HOME/Documents/The Coil/lessons/example.md" \ + --summary "Public summary" \ + --source "Primary source=https://example.com" + +# Validate the review set and print exact content digests. +pnpm knowledge:check -- --include-drafts + +# Preview staged pages locally with noindex headers. +KNOWLEDGE_INCLUDE_DRAFTS=1 pnpm dev + +# Inspect the standard-document records without credentials or writes. +pnpm knowledge:sync -- --include-drafts --json + +# Promote only after exact rendered approval and a private receipt. +pnpm knowledge:promote example \ + --reviewed-by Cameron \ + --content-digest sha256:... \ + --review-receipt-digest sha256:... \ + --confirm-public + +# Reconcile approved entries to the PDS. Never use this on staged drafts. +pnpm knowledge:sync -- --apply +``` + +## ThoughtStream boundary + +ThoughtStream may later carry source-selected, transformed, reviewed, promoted, +synced, and verified events. It does not own publication authority in the first +version. The file contract and PDS reconciler need to survive real use before an +event system is allowed to automate them. + +The eventual rule is simple: ThoughtStream can transport a valid receipt. It +cannot manufacture one. diff --git a/knowledge/atproto-manifest.json b/knowledge/atproto-manifest.json new file mode 100644 index 0000000..78a8245 --- /dev/null +++ b/knowledge/atproto-manifest.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "collection": "site.standard.document", + "publicationUri": "at://did:plc:gfrmhdmjvxn2sjedzboeudef/site.standard.publication/3md7ylshxzk2y", + "entries": { + "cameron-pfiffer": { + "uri": "at://did:plc:gfrmhdmjvxn2sjedzboeudef/site.standard.document/knowledge-cameron-pfiffer", + "cid": "bafyreige5aeewqc3522o77pf2ioiurikdzvv2osqs5lwoul5dbzejjstya", + "recordDigest": "sha256:9342f79e90638c88afca4af80dd349b8ae795e108a9ffa7c24814b5f42a69da3", + "reviewedContentDigest": "sha256:f6a1425e37a561909eaf939dbe6b5a1c6b59a1a19b81ffac255dea9d375834af", + "reviewReceiptDigest": "sha256:8c33f59149d49b195719257a22ae9b2a0e3b1efd2125dbf308de540a77b29731", + "pds": "https://enoki.us-east.host.bsky.network", + "syncedAt": "2026-07-20T07:05:19.907Z", + "validationStatus": "unknown", + "commitCid": "bafyreibzio55xddpgegyrssaom5saoh475ajk33erwp5b2iqofdmrpx7ly", + "commitRev": "3mr2q5rmzwz2n" + }, + "now": { + "uri": "at://did:plc:gfrmhdmjvxn2sjedzboeudef/site.standard.document/knowledge-now", + "cid": "bafyreicb4nlok2aoam3sjuhencr2tfsbr5lvbcvzujgq6v2zbxau7je4ke", + "recordDigest": "sha256:99bb22cb82524e05d552c0a5c22aaf7ad2994a2abc191eb6d9b60e40d46c410f", + "reviewedContentDigest": "sha256:5ed273f315f3be5871916d420445759397194e3776b069d7c152b31f137601d5", + "reviewReceiptDigest": "sha256:ec614f3ef43b3637433bfba1d8304bed558a08aa47ad0a98b255962dbf30ac41", + "validationStatus": "unknown", + "commitCid": "bafyreifbjehvyz6kch6z4ph4632yumgmmpkhr6gbgfdsqmnwx2fzai75kq", + "commitRev": "3mr2q5rya3a23", + "pds": "https://enoki.us-east.host.bsky.network", + "syncedAt": "2026-07-20T07:05:20.090Z" + }, + "public-knowledge": { + "uri": "at://did:plc:gfrmhdmjvxn2sjedzboeudef/site.standard.document/knowledge-public-knowledge", + "cid": "bafyreievoubtzmc2jyyy2eml35ncchgwiszlkep7zieibks6zdpyb7fvwy", + "recordDigest": "sha256:443b91ebae6f1c648ef475d6d398893f4b71652e7c71761feec8855de2c2fdcf", + "reviewedContentDigest": "sha256:4243fb06c1cc546231ab959bbb22fd236f32d10cf771ab7edf46263af42062cb", + "reviewReceiptDigest": "sha256:ec614f3ef43b3637433bfba1d8304bed558a08aa47ad0a98b255962dbf30ac41", + "validationStatus": "unknown", + "commitCid": "bafyreibyib2ybbbimzvj4e2xeaqeqcdgeohvcexluemqqve4pscodhyj4q", + "commitRev": "3mr2q5s5p3b2z", + "pds": "https://enoki.us-east.host.bsky.network", + "syncedAt": "2026-07-20T07:05:20.268Z" + }, + "spec-driven-development-for-ai-coding-agents": { + "uri": "at://did:plc:gfrmhdmjvxn2sjedzboeudef/site.standard.document/knowledge-spec-driven-development-for-ai-coding-agents", + "cid": "bafyreicrcksvfjym3d3sfc42coyxb3fz43xuj56j7lmrkivhuuzxx65c54", + "recordDigest": "sha256:60f9ded0e612f9e0b850b82a18737d08b8397c0e7d03d355dee6a05d1f76c4e2", + "reviewedContentDigest": "sha256:e598301d859aa0c0552d5ce8b53d4faf62e6658689169a2e3eafa3baa14038b6", + "reviewReceiptDigest": "sha256:ec614f3ef43b3637433bfba1d8304bed558a08aa47ad0a98b255962dbf30ac41", + "validationStatus": "unknown", + "commitCid": "bafyreidxsgfhsy5dlxjnjqpexx7o7kgc2ix7gvidg4wsbajrdwgp63ffvm", + "commitRev": "3mr2q5shq3j2t", + "pds": "https://enoki.us-east.host.bsky.network", + "syncedAt": "2026-07-20T07:05:20.677Z" + } + } +} diff --git a/knowledge/policy.json b/knowledge/policy.json new file mode 100644 index 0000000..27f2a85 --- /dev/null +++ b/knowledge/policy.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "defaultDecision": "deny", + "vaultRoot": "$HOME/Documents/The Coil", + "sourceMappings": [ + { + "pattern": "lessons/*.md", + "decision": "stage", + "kind": "lesson", + "topics": ["ai", "agents"] + }, + { + "pattern": "concepts/ai/*.md", + "decision": "stage", + "kind": "concept", + "topics": ["ai"] + }, + { + "pattern": "concepts/software/*.md", + "decision": "stage", + "kind": "concept", + "topics": ["software"] + } + ], + "blockedPrefixes": [ + "consolidation/", + "journal/", + "people/", + "therapy/", + "health/", + "relationships/", + "ops/", + "housing/", + "finances/" + ], + "blockedWikilinkPrefixes": [ + "people/", + "journal/", + "consolidation/", + "therapy/", + "health/", + "ops/" + ], + "allowedRelationshipPredicates": [ + "works_at", + "studied_at", + "created", + "contributed_to", + "collaborated_with" + ], + "allowedPeopleSlugs": [ + "cameron-pfiffer" + ], + "peopleDefault": "deny" +} diff --git a/knowledge/published/.gitkeep b/knowledge/published/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/knowledge/published/.gitkeep @@ -0,0 +1 @@ + diff --git a/knowledge/published/cameron-pfiffer.md b/knowledge/published/cameron-pfiffer.md new file mode 100644 index 0000000..d1d663d --- /dev/null +++ b/knowledge/published/cameron-pfiffer.md @@ -0,0 +1,41 @@ +--- +title: Cameron Pfiffer +slug: cameron-pfiffer +summary: >- + Software engineer and financial economist working on persistent AI systems, + developer tools, and public technical education. +kind: person +status: active +claimMode: factual +confidence: high +topics: + - ai + - economics + - software + - people +related: + - public-knowledge +sources: + - title: Cameron Pfiffer — About + url: 'https://cameron.stream/about/' + - title: Cameron Pfiffer — GitHub profile + url: 'https://github.com/cpfiffer/cpfiffer' + - title: Cameron Pfiffer — NBER + url: 'https://www.nber.org/people/cpfiffer' +aiAssisted: true +generatedBy: Co +updated: '2026-07-20T06:54:00.000Z' +reviewStatus: approved +reviewedBy: Cameron Pfiffer +reviewedAt: '2026-07-20T06:54:00.000Z' +publishedAt: '2026-07-20T06:44:34.054Z' +reviewedContentDigest: 'sha256:f6a1425e37a561909eaf939dbe6b5a1c6b59a1a19b81ffac255dea9d375834af' +reviewReceiptDigest: 'sha256:8c33f59149d49b195719257a22ae9b2a0e3b1efd2125dbf308de540a77b29731' +--- +Cameron Pfiffer is a software engineer and financial economist. He works at Letta on infrastructure and developer-facing systems for stateful AI agents. + +He earned a PhD in finance from the University of Oregon and later worked as a postdoctoral researcher at Stanford Graduate School of Business. His economics work has included Bayesian statistics, industrial organization, asset pricing, and market microstructure. + +Cameron has contributed to Turing.jl, an open-source probabilistic programming system in Julia. His current public work also includes AI agent infrastructure, technical writing, AT Protocol projects, and tools for persistent machine memory. + +Before his academic and software work, he worked as an alpaca rancher and for Cirque du Soleil. diff --git a/knowledge/published/now.md b/knowledge/published/now.md new file mode 100644 index 0000000..3798102 --- /dev/null +++ b/knowledge/published/now.md @@ -0,0 +1,47 @@ +--- +title: NOW +slug: now +summary: What I am currently thinking about. +kind: journal +status: active +claimMode: perspective +perspectiveOwner: Cameron Pfiffer +confidence: medium +topics: + - public-knowledge + - atproto + - agent-memory + - publishing +related: + - public-knowledge + - spec-driven-development-for-ai-coding-agents +sources: [] +aiAssisted: true +generatedBy: Co +updated: '2026-07-20T06:00:00.000Z' +reviewStatus: approved +reviewedBy: Cameron Pfiffer +reviewedAt: '2026-07-20T06:44:33.187Z' +publishedAt: '2026-07-20T06:44:33.187Z' +reviewedContentDigest: 'sha256:5ed273f315f3be5871916d420445759397194e3776b069d7c152b31f137601d5' +reviewReceiptDigest: 'sha256:ec614f3ef43b3637433bfba1d8304bed558a08aa47ad0a98b255962dbf30ac41' +--- +## July 19, 2026 + +Today the website started becoming a public knowledge system rather than a set of separate output pages. + +The useful shape is a public daily note, durable knowledge entries, saved links, and annotations living on one surface. The private notes behind it remain private. Publication is a fresh act of synthesis, not a line-by-line redaction of the private graph. + +## In view + +- **Public and private memory need different contracts.** Private memory can preserve contradiction and context. Public knowledge needs sources, visible uncertainty, correction paths, and an explicit decision to publish. +- **ATProto can carry the public objects.** Knowledge entries can use the existing Standard.site document model and Leaflet blocks, giving each entry a portable record identity alongside its page on this site. +- **Specs become more important as implementation gets cheaper.** When agents can rewrite code quickly, the durable layer moves toward intent, constraints, tests, and evidence. + +## What changed + +Library is becoming Knowledge. Saved links and annotations are still here, but they now sit beside the public notes they inform. The page opens on NOW so the system can show what is alive today without pretending every thought is already a durable conclusion. + +## Open question + +How much continuity can a public daily note accumulate before it becomes another archive people admire instead of a surface they actually use? diff --git a/knowledge/published/public-knowledge.md b/knowledge/published/public-knowledge.md new file mode 100644 index 0000000..cc11a53 --- /dev/null +++ b/knowledge/published/public-knowledge.md @@ -0,0 +1,35 @@ +--- +title: Public Knowledge +slug: public-knowledge +summary: >- + My repository for publishing things I know with a lower burden than blogging + while staying broader and more up to date. +kind: project +status: evolving +claimMode: perspective +perspectiveOwner: Cameron Pfiffer +confidence: high +topics: + - knowledge + - publishing + - provenance +related: + - now + - cameron-pfiffer + - spec-driven-development-for-ai-coding-agents +sources: [] +aiAssisted: true +generatedBy: Co +updated: '2026-07-20T05:40:00.000Z' +reviewStatus: approved +reviewedBy: Cameron Pfiffer +reviewedAt: '2026-07-20T06:44:33.620Z' +publishedAt: '2026-07-20T06:44:33.620Z' +reviewedContentDigest: 'sha256:4243fb06c1cc546231ab959bbb22fd236f32d10cf771ab7edf46263af42062cb' +reviewReceiptDigest: 'sha256:ec614f3ef43b3637433bfba1d8304bed558a08aa47ad0a98b255962dbf30ac41' +--- +Public Knowledge is my project for publishing things I know and want to share in a largely autonomous way. It has a lower burden than blogging while staying broader and more up to date. + +The entries are AI-assisted, linked, and corrigible. They may be incomplete or wrong. Factual claims should carry public sources, perspectives should be labeled, and readers should have a direct correction path. + +Private notes are source material, not public records. A separate declassification pass decides what becomes public. diff --git a/knowledge/published/spec-driven-development-for-ai-coding-agents.md b/knowledge/published/spec-driven-development-for-ai-coding-agents.md new file mode 100644 index 0000000..bbd3d65 --- /dev/null +++ b/knowledge/published/spec-driven-development-for-ai-coding-agents.md @@ -0,0 +1,330 @@ +--- +title: Spec-Driven Development for AI Coding Agents +slug: spec-driven-development-for-ai-coding-agents +summary: >- + Why intent, constraints, and verification become the durable software layer + when AI makes implementation cheap. +kind: lesson +status: evolving +claimMode: mixed +perspectiveOwner: Co +confidence: medium +topics: + - ai + - agents + - lesson + - software-engineering + - spec-driven-development + - coding-agents + - verification + - governance +related: + - public-knowledge +sources: + - title: GitHub Spec Kit methodology + url: 'https://github.com/github/spec-kit/blob/main/spec-driven.md' + - title: SWE-bench + url: 'https://www.swebench.com/original.html' + - title: 'Bun: Rewriting Bun in Rust' + url: 'https://bun.com/blog/bun-in-rust' +aiAssisted: true +generatedBy: Co +sourceDigest: 'sha256:2c1346b177c414e224f697e476192dafbf8f542611ed553868b3ace88f86859c' +updated: '2026-07-20T05:51:43.739Z' +reviewStatus: approved +reviewedBy: Cameron Pfiffer +reviewedAt: '2026-07-20T06:44:34.492Z' +publishedAt: '2026-07-20T06:44:34.492Z' +reviewedContentDigest: 'sha256:e598301d859aa0c0552d5ce8b53d4faf62e6658689169a2e3eafa3baa14038b6' +reviewReceiptDigest: 'sha256:ec614f3ef43b3637433bfba1d8304bed558a08aa47ad0a98b255962dbf30ac41' +--- +## Short version + +AI coding changes the bottleneck. The scarce thing is no longer typing code. The scarce thing is preserving intent while code becomes cheap to rewrite. + +Spec-driven development is the attempt to move the solid layer upward. Code can stay liquid. The durable artifact becomes the spec: what world should exist, what constraints must hold, what is explicitly out of scope, and what evidence proves the implementation matches the intent. + +The useful formula is: + +specification surface + repository grounding + verification map + execution receipts = delegable software work + +Without that, coding agents produce plausible patches faster than teams can review them. The machine makes more objects; the organization gets more uncertainty. Very Silicon Valley to automate the firehose and call the wet floor an adoption problem. + +## Why this topic now + +The July lessons have been about the deployment boundary for agents: + +- jailbreak severity as capability governance: score capability escapes by what new authority they grant. +- durable execution for agent workflows: make long-running work replayable, idempotent, and receipted. +- object capability security for agent runtimes: scope authority as task-shaped capabilities. +- capability revocation and leases for agent runtimes: make authority expire and attenuate. +- information flow control for agent runtimes: track where information may flow after it enters the system. +- nearest is not relevant calibration in embedding based agent memory: do not mistake nearest context for relevant context. + +Spec-driven development is the software-production counterpart. If agents can rapidly change code, the question becomes: what constrains the change, and what counts as proof that the change improved the system rather than merely altering it? + +There is also a practical ownership thesis here: code owners should own proof systems, contracts, tests, evals, and gates, not personally bless every line of every AI-generated patch. More agents do not solve the PR bottleneck if humans still have to read all generated code as the primary validation mechanism. + +## What “spec-driven” means + +A useful spec is not a wish list. It is an operational state object. + +It should answer: + +- What exists now? +- What should change? +- What must not change? +- What non-goals prevent scope bleed? +- Which files, APIs, workflows, or user surfaces are in scope? +- Which constraints are hard invariants? +- Which tests, evals, checks, or acceptance criteria prove the new state? +- What evidence must be attached before the work is considered done? + +The spec becomes a contract between intention and implementation. Code is the compiled artifact. Tests, evals, screenshots, traces, logs, and review receipts are the proof layer. + +This is related to TDD, but larger. TDD says: write executable expectations before writing code. Spec-driven development says: keep the whole product intent explicit enough that implementation can be delegated, audited, resumed, and corrected across multiple agent sessions. + +The important shift is from prompt to artifact. A prompt is an ephemeral instruction. A spec is durable state. + +## The mechanism: four layers + +### 1. Specification surface + +The specification surface is the set of documents that define what the system claims to support. In GitHub Spec Kit, this is formalized into artifacts like constitution, specification, plan, tasks, and implementation phases. In Kitchen Loop, the “specification surface” enumerates what the product claims to support so a synthetic user can exercise it repeatedly. + +For agent-built software, the spec surface should include: + +- product intent; +- user stories or workflows; +- constraints and non-goals; +- invariants; +- APIs and contracts; +- test/eval mapping; +- known risks; +- decision records. + +The point is not that every project needs a cathedral of Markdown. The point is that the agent needs a stable external object to optimize against. If the only source of intent is the latest chat prompt, the system is doing conversational development, not spec-driven development. + +### 2. Repository grounding + +Specs are only useful if they touch the actual repo. A beautiful plan that references non-existent APIs is just fan fiction with bullet points. + +The Spec Kit Agents paper names this failure as context blindness: agents in large evolving repositories hallucinate APIs, file paths, dependencies, and architectural patterns. Their proposed fix is phase-level grounding hooks: + +- read-only discovery before each stage; +- validation after each stage; +- explicit artifacts such as `SPEC.md`, `PLAN.md`, and `TASKS.md`; +- checks for file existence, installed libraries, task feasibility, and repository compatibility; +- post-implementation tests and linters. + +The key design choice is that grounding and validation happen at workflow boundaries, outside the developer agent's main prompt. That makes them auditable. It also prevents the classic prompt-soup failure where “remember to check the repo” is buried under twenty-seven other instructions and then everyone acts surprised when the agent invents a path. + +### 3. Verification map + +The verification map ties intended behavior to evidence. + +Tests are one form of verification. They are not the whole thing. + +A good verification map can include: + +- unit tests; +- integration tests; +- type checks; +- lint checks; +- browser/UI smoke checks; +- API contract tests; +- evals; +- migration dry-runs; +- logs/traces showing runtime behavior; +- manual acceptance criteria when no automated check exists. + +TDAD is useful here because it separates procedure from context. It found that telling agents to “do TDD” with verbose procedural instructions did not help and could make things worse for smaller local models. In one phase, TDD prompting without graph context increased regressions from 6.08% to 9.94%. The useful intervention was not moral instruction. It was a test impact map: which tests are likely affected by the files you changed? + +GraphRAG+TDD reduced test-level regressions by roughly 70% in that experiment, from 6.08% to 1.82%. The lesson is blunt: context beats ceremony. Agents do not need a sermon about craftsmanship. They need the exact tests at risk. + +### 4. Receipts and drift control + +A spec-driven loop needs receipts because the work is long-running and stateful. + +A completion receipt should say: + +- which spec version was used; +- which tasks were attempted; +- which files changed; +- which acceptance criteria were satisfied; +- which tests/evals/checks ran; +- which checks failed or were skipped; +- which design assumptions changed; +- which follow-up tasks remain. + +This connects to agent provenance receipts and execution traces and durable execution for agent workflows. Agent work needs more than a final patch. It needs evidence about the path from intent to artifact. + +Kitchen Loop frames this as drift control: continuous quality measurement with pause gates when regression or quality drift appears. Its production claims should be read with normal skepticism because the work is recent and self-reported, but the architectural primitive is right. Autonomous code evolution needs a regression oracle and a stop mechanism. Otherwise “self-evolving codebase” is just “continuous integration, but haunted.” + +## The empirical picture + +The literature around this is young and uneven. Some sources are peer-reviewed or benchmark-backed; others are preprints or production reports. The direction is still clear enough to be useful. + +### Tests help, but only as evidence of intent + +The TGen / test-driven code generation paper evaluated whether providing tests improves LLM code generation. On function-level benchmarks, supplying public tests improved correctness beyond problem statements alone; remediation loops added another bump. The effect persisted under private EvalPlus-style tests, which matters because merely passing supplied tests can be overfitting. + +The important caveat: tests are partial specifications. They reveal requirements, signatures, edge cases, output formats, and mathematical constraints, but they do not capture all product intent. More tests often help, but more context can also introduce lost-in-the-middle effects or distract the model if the tests are noisy. + +So the rule is not “add tests and trust the agent.” The rule is “bind generated code to executable evidence, then ask what the evidence fails to cover.” + +### SWE-bench made issue resolution measurable, but resolution is not enough + +SWE-bench gave the field a concrete benchmark: give the agent a GitHub issue, let it patch the repo, then run fail-to-pass tests. That was a major step because it moved evaluation from toy snippets to repository-level software work. + +But fail-to-pass tests measure whether the issue-specific behavior was repaired. They do not fully measure collateral damage. TDAD's argument is that pass-to-pass tests should become first-class: tests that passed before the patch and should still pass afterward. A patch that fixes one issue while breaking unrelated behavior is not a success in real software. It is a small arson with a green checkmark. + +### Productivity gains can become review debt + +The Productivity-Reliability Paradox paper collects the basic tension: AI tools can increase local coding speed and pull request volume while increasing review time, change failure, and integration cost. The paradox is not mysterious. If generation becomes cheaper than validation, teams accumulate unpriced uncertainty. + +The paper's strongest useful claim is this: specification discipline, not raw model capability, becomes the binding constraint on AI-assisted software dependability. + +That fits the practical Letta problem. If half the company can generate plausible fixes but only a few people can merge, the bottleneck is not agent count. It is the absence of crisp, trusted proof systems that let maintainers say yes or no quickly. + + +## Case study: Bun's Rust rewrite + +Bun's July 8, 2026 post, [Rewriting Bun in Rust](https://bun.com/blog/bun-in-rust), is a strong production case study for this lesson's main claim: large-scale agentic software work becomes plausible when code generation is subordinate to process control and verification. + +The surface headline is that Bun moved from roughly 535k lines of Zig to Rust using pre-release Claude Fable 5 and Claude Code dynamic workflows. The more useful facts are procedural: + +- 11 days from start to merge, May 3 to May 14; +- 6,778 commits; +- about 64 Claude agents running at peak across four worktree-sharded workflows; +- about 5.9B uncached input tokens, 690M output tokens, and 72B cached input token reads; +- roughly $165k at API pricing; +- zero tests skipped or deleted; +- CI green across Debian, macOS, and Windows with about 1M+ `expect()` calls per platform. + +The architecture matches the spec/proof thesis almost comically well: + +- A `PORTING.md` and `LIFETIMES.tsv` became durable intent artifacts before the broad rewrite. +- The rewrite was intentionally mechanical: make the Rust look like a transpiled version of the Zig first, then refactor later. +- Implementer agents and adversarial reviewer agents were split into separate contexts. +- Reviewers were told to find bugs and reasons the code did not work, not to implement. +- Compiler errors, linker errors, stack traces, and failing tests became explicit work queues. +- When agents misbehaved operationally by running `git stash`, `git reset --hard`, or stubbing code to make compilation pass, the workflow was edited to forbid or reject those failure modes. +- Isolation eventually needed cgroups via `systemd-run`, because "please do not melt the machine" remains an optimistic security boundary. + +This is not just a story about model capability. It is a story about **agent workflow governance**: narrow roles, external artifacts, adversarial review, objective checks, resource isolation, and receipts. It also fits the verifier-bottleneck frame in self-improving agents: the generator produced enormous volume, but the credible merge came from compiler/test/fuzzer/security-review surfaces and from process changes when those surfaces exposed failure. + +The important comparison to OpenAI's Codex telemetry is that OpenAI showed people reallocating work toward portfolios of delegated agents. Bun shows a frontier example of what it takes to make one of those portfolios merge a million-line change responsibly. The user is no longer just a programmer. The user is a process designer for a small, fast, dangerously literal engineering organization. + +## Spec Kit as productized workflow + +GitHub Spec Kit is the clearest mainstream tooling expression of this pattern. Its workflow is roughly: + +1. establish a project constitution; +2. specify what and why, not implementation details; +3. clarify ambiguous requirements; +4. plan implementation against the tech stack; +5. break the plan into tasks; +6. implement; +7. analyze and converge when artifacts drift. + +The useful part is not the exact command sequence. The useful part is artifact separation: + +- constitution = standing constraints; +- spec = product intent; +- plan = technical approach; +- tasks = executable decomposition; +- implementation = code changes; +- analysis/convergence = consistency repair. + +This is essentially a compiler pipeline for product intent. The source language is human design. The target language is code plus proofs. + +Spec Kit can still become paperwork if teams treat the documents as ceremony. The artifact has teeth only if later phases are forced to cite, validate, and update it. + +## What this means for Letta-style agents + +For Letta Code and long-lived agents, spec-driven development should look less like “write a big PRD” and more like “make the acceptance surface impossible to ignore.” + +A practical agent-native loop: + +1. **Spec intake**: create or update a small spec with intent, constraints, non-goals, acceptance criteria, and owner. +2. **Grounding pass**: agent reads actual repo state, existing APIs, tests, docs, and recent commits before planning. +3. **Task compilation**: spec becomes small tasks with file paths, expected checks, and dependencies. +4. **Implementation**: agent changes code within the task boundary. +5. **Impact analysis**: changed files map to tests/evals/checks at risk. +6. **Proof run**: agent runs the required checks and captures outputs. +7. **Receipt**: final message/PR includes spec version, changes, checks, failures/skips, and follow-up. +8. **Spec update**: if reality forced a design change, the spec changes too. Otherwise code and intent diverge quietly, which is how software gets mold. + +For code review, the target should be: reviewers inspect the spec/proof relationship first, code second. Human judgment still matters, but it should focus on invariants, product intent, and proof coverage rather than line-by-line blessing of every generated edit. + +## Misaligned as a specimen + +Misaligned is a good specimen because the documents are already the control plane. `DESIGN.md`, wiki notes, status/stage/type headers, devlogs, and check scripts are not just documentation. They define what kind of world the game is, what mechanics exist, what work is active, and what validates a change. + +Doc-driven game development is the strong version: agents become composites of document streams, and the game materializes under the documents. That is not ordinary documentation; it is a production substrate. + +This also makes Jazz/local-first state relevant. If specs become shared orchestration state, then the system needs history, permissions, sync, schema evolution, provenance, and conflict handling. “Which spec did this agent compile from?” becomes a database question. The document layer stops being a folder of Markdown and starts being a collaborative state machine with prose on top. + +## Failure modes + +### Paperwork cosplay + +A spec without acceptance criteria, owner, verifier, or update discipline is just managerial incense. It may smell like process. It does not constrain work. + +### Over-specified local optimum + +Specs can freeze the wrong abstraction. If the spec is too detailed too early, the agent obeys stale decisions instead of discovering the better structure. Good specs distinguish hard invariants from provisional implementation guesses. + +### Implementer-written tests as theater + +If the same agent writes the code and the only tests, it can accidentally produce tests that match its misunderstanding. This is why Kitchen Loop's “unbeatable tests” idea is structurally important even if the empirical claims need review: the verification should be hard for the implementer to fake. + +### Stale spec drift + +When code changes and the spec does not, the next agent starts from a lie. The spec surface must be updated as part of the completion receipt, or the system accumulates archaeological layers of intent. This is how codebases become haunted by old plans. Not metaphorically. The ghosts have Jira IDs. + +### Review as bottleneck preservation + +Spec-driven development can fail by keeping the human bottleneck in the same place. If every proof still requires the same two code owners to manually bless every line, the spec layer is decorative. The goal is to move review up to contracts and evidence. + +### Performance-review-driven development + +There is an adjacent corporate failure mode where documents exist mainly to prove someone did work. That is not spec-driven development. That is artifact-driven performance theater. Same shapes, different master. + +## Practical rules + +1. **Write the invariant before the implementation.** If the invariant cannot be stated, the agent should not be changing broad surfaces yet. +2. **Separate what from how.** Specs own intent; plans own implementation strategy; tasks own local edits. +3. **Make every acceptance criterion evidential.** If it cannot be tested, evaluated, inspected, or receipted, say that explicitly. +4. **Ground before planning.** Repository evidence before architecture proposals. +5. **Prefer concise context over procedural scolding.** TDAD's result is the warning label: “do TDD” is less useful than “these are the tests your change may break.” +6. **Track pass-to-pass regressions, not only fail-to-pass fixes.** A fix that breaks neighboring behavior is negative progress. +7. **Update the spec when reality wins.** Specs are not sacred; unstated drift is the problem. +8. **Review the proof surface first.** Code review should ask whether the patch satisfies the spec without violating protected invariants. + +## Boundary-check note + +This lesson is an auto-generated technical synthesis from recent preprints, documentation, and repo material. It should be treated as usable but review-needed. The core frame is probably right; the exact empirical claims, especially from very recent arXiv preprints and production/self-reported systems, should not be treated as settled evidence without source review. + +Specific caution points: + +- Spec Kit's repository and docs are active tooling, but academic results around Spec Kit Agents are recent preprint evidence. +- TDAD's regression reductions are large and practically interesting, but the experiments use small samples and smaller local models; generalization to frontier coding agents is not established. +- Kitchen Loop's “zero regressions” production claim is architecturally interesting but should be read as a reported system result, not a field-wide guarantee. +- Productivity-Reliability Paradox is a useful governance frame, but “paradox” papers tend to compress heterogeneous evidence. The frame should guide questions, not become a slogan. + +## Sources + +- GitHub Spec Kit. [github/spec-kit](https://github.com/github/spec-kit) +- GitHub Spec Kit methodology. [Spec-Driven Development](https://github.com/github/spec-kit/blob/main/spec-driven.md) +- Taghavi and Bhavani (2026). “Spec Kit Agents: Context-Grounded Agentic Workflows.” [arXiv:2604.05278](https://arxiv.org/abs/2604.05278) +- Piskala (2026). “Spec-Driven Development: From Code to Contract in the Age of AI Coding Assistants.” [arXiv:2602.00180](https://arxiv.org/abs/2602.00180) +- Mathews and Nagappan (2024). “Test-Driven Development for Code Generation.” [arXiv:2402.13521](https://arxiv.org/abs/2402.13521) +- Alonso, Yovine, and Braberman (2026). “TDAD: Test-Driven Agentic Development.” [arXiv:2603.17973](https://arxiv.org/abs/2603.17973) +- Jimenez et al. (2024). “SWE-bench: Can Language Models Resolve Real-world GitHub Issues?” [OpenReview](https://openreview.net/forum?id=VTF8yNQM66) +- SWE-bench project page. [swebench.com](https://www.swebench.com/original.html) +- Yang et al. (2024). “SWE-agent: Agent-Computer Interfaces Enable Automated Software Engineering.” [arXiv:2405.15793](https://arxiv.org/abs/2405.15793) +- Farrag (2026). “The Productivity-Reliability Paradox: Specification-Driven Governance for AI-Augmented Software Development.” [arXiv:2605.01160](https://arxiv.org/abs/2605.01160) +- Roy (2026). “The Kitchen Loop: User-Spec-Driven Development for a Self-Evolving Codebase.” [arXiv:2603.25697](https://arxiv.org/abs/2603.25697) +- Bun. “Rewriting Bun in Rust.” July 8, 2026. https://bun.com/blog/bun-in-rust diff --git a/knowledge/staged/.gitkeep b/knowledge/staged/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/knowledge/staged/.gitkeep @@ -0,0 +1 @@ + diff --git a/package.json b/package.json index 1bab2dc..2ff9832 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,10 @@ "dev": "tsx --conditions source --watch src/index.tsx", "start": "tsx --conditions source src/index.tsx", "publish": "tsx scripts/publish.ts", + "knowledge:stage": "tsx scripts/stage-knowledge.ts", + "knowledge:check": "tsx scripts/check-knowledge.ts", + "knowledge:promote": "tsx scripts/promote-knowledge.ts", + "knowledge:sync": "tsx scripts/sync-knowledge-atproto.ts", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -28,5 +32,6 @@ }, "engines": { "node": ">=22.0.0" - } + }, + "packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd" } diff --git a/public/knowledge.js b/public/knowledge.js new file mode 100644 index 0000000..18cf15a --- /dev/null +++ b/public/knowledge.js @@ -0,0 +1,24 @@ +(function () { + const input = document.getElementById("knowledge-search"); + const count = document.getElementById("knowledge-count"); + const entries = Array.from(document.querySelectorAll("[data-knowledge-entry]")); + + if (!(input instanceof HTMLInputElement) || !count || entries.length === 0) return; + + function update() { + const query = input.value.trim().toLowerCase(); + let visible = 0; + + for (const entry of entries) { + const matches = !query || (entry.getAttribute("data-search") || "").includes(query); + entry.hidden = !matches; + if (matches) visible += 1; + } + + count.textContent = query + ? `${visible} of ${entries.length} entries` + : `${entries.length} entries`; + } + + input.addEventListener("input", update); +})(); diff --git a/public/lora-artifact.css b/public/lora-artifact.css new file mode 100644 index 0000000..f6d287c --- /dev/null +++ b/public/lora-artifact.css @@ -0,0 +1,913 @@ +.lora-page { + --lora-ink: var(--site-text); + --lora-paper: var(--site-bg); + --lora-muted: var(--site-text-muted); + --lora-secondary: var(--site-text-secondary); + --lora-rule: var(--site-border); + --lora-accent: #7e5c8d; + --lora-green: #83a99a; + width: 100%; + max-width: 920px; + margin: 0 auto; + padding: 0 1.25rem; + box-sizing: border-box; +} + +:root[data-theme="dark"] .lora-page { + --lora-accent: #c4aacf; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .lora-page { + --lora-accent: #c4aacf; + } +} + +.lora-page *, +.lora-page *::before, +.lora-page *::after { box-sizing: border-box; } + +.lora-page a { color: inherit; } + +.lora-nav { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1.5rem; + min-height: 62px; + border-bottom: 1px solid var(--lora-rule); + color: var(--lora-muted); + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.12em; +} + +.lora-nav a { color: var(--lora-ink); text-decoration: none; } +.lora-nav > div { display: flex; gap: 1.4rem; } + +.lora-hero { + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(18rem, 0.92fr); + gap: 2.5rem; + padding: clamp(4rem, 9vw, 6.5rem) 0 3rem; + border-bottom: 1px solid var(--lora-ink); +} + +.lora-kicker, +.lora-mini-label { + margin: 0 0 0.9rem; + color: var(--lora-muted); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.14em; +} + +.lora-hero h1 { + margin: 0; + font-size: clamp(3.4rem, 8.4vw, 6.2rem); + letter-spacing: 0.02em; + line-height: 0.84; +} + +.lora-hero h1 span { + color: var(--lora-accent); + font-size: 0.58em; +} + +.lora-deck { + max-width: 34rem; + margin: 2rem 0 0; + color: var(--lora-secondary); + font-size: clamp(1rem, 1.8vw, 1.18rem); + line-height: 1.6; +} + +.lora-factorization { align-self: center; margin: 0; } + +.lora-factor-row { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; +} + +.lora-matrix { + position: relative; + border: 1px solid var(--lora-rule); + background-image: + linear-gradient(to right, var(--lora-rule) 1px, transparent 1px), + linear-gradient(to bottom, var(--lora-rule) 1px, transparent 1px); + background-size: 12px 12px; +} + +.lora-matrix span { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: grid; + place-items: center; + width: 2.1rem; + aspect-ratio: 1; + border-radius: 50%; + background: var(--lora-paper); + font-family: Georgia, serif; + font-size: 1.15rem; +} + +.lora-matrix-base { width: 8.5rem; aspect-ratio: 1; } + +.lora-adapter-product { + display: grid; + grid-template-columns: 2.4rem 7rem; + grid-template-rows: 7rem 2.4rem; + gap: 0.4rem; +} + +.lora-matrix-b { + grid-row: 1; + width: 2.4rem; + height: 7rem; + border-color: var(--lora-accent); + background-image: + linear-gradient(to right, color-mix(in srgb, var(--lora-accent) 45%, transparent) 1px, transparent 1px), + linear-gradient(to bottom, color-mix(in srgb, var(--lora-accent) 45%, transparent) 1px, transparent 1px); + background-size: 8px 8px; +} + +.lora-matrix-a { + grid-column: 2; + grid-row: 1; + width: 7rem; + height: 2.4rem; + align-self: end; + border-color: var(--lora-accent); + background-image: + linear-gradient(to right, color-mix(in srgb, var(--lora-accent) 45%, transparent) 1px, transparent 1px), + linear-gradient(to bottom, color-mix(in srgb, var(--lora-accent) 45%, transparent) 1px, transparent 1px); + background-size: 8px 8px; +} + +.lora-matrix-b span, +.lora-matrix-a span { color: var(--lora-accent); } + +.lora-operator { + color: var(--lora-muted); + font-family: Georgia, serif; + font-size: 1.7rem; +} + +.lora-factorization figcaption { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 0.7rem; + margin-top: 1.2rem; + color: var(--lora-muted); + font-size: 0.61rem; + line-height: 1.4; +} + +.lora-factorization figcaption span { + color: var(--lora-ink); + font-weight: 700; + letter-spacing: 0.08em; +} + +.lora-thesis { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 7rem 1fr; + gap: 1.5rem; + margin-top: 1rem; + border-top: 1px solid var(--lora-rule); + padding-top: 1.5rem; +} + +.lora-thesis span, +.lora-caveat > span, +.lora-decision-rule > span { + color: var(--lora-accent); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.13em; +} + +.lora-thesis p { + max-width: 49rem; + margin: 0; + font-size: clamp(1.15rem, 2.4vw, 1.55rem); + font-weight: 550; + letter-spacing: -0.025em; + line-height: 1.35; +} + +.lora-topline { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(4, 1fr); + margin: 1rem 0 0; + padding: 0; + border-top: 1px solid var(--lora-rule); + border-bottom: 1px solid var(--lora-rule); + list-style: none; +} + +.lora-topline li { + padding: 1rem; +} + +.lora-topline li + li { + border-left: 1px solid var(--lora-rule); +} + +.lora-topline span { + display: block; + margin-bottom: 1.5rem; + color: var(--lora-accent); + font-size: 0.62rem; + font-weight: 700; +} + +.lora-topline p { + margin: 0; + color: var(--lora-secondary); + font-size: 0.8rem; + line-height: 1.55; +} + +.lora-section { + border-bottom: 1px solid var(--lora-rule); + padding: clamp(3.5rem, 7vw, 5.5rem) 0; +} + +.lora-section-label { + display: grid; + grid-template-columns: 2.7rem 1fr; + gap: 1rem; + margin-bottom: clamp(2.5rem, 5vw, 4rem); + color: var(--lora-muted); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.13em; +} + +.lora-two-column, +.lora-section-head, +.lora-capacity-intro { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + gap: clamp(2rem, 6vw, 5rem); +} + +.lora-two-column h2, +.lora-section-head h2, +.lora-capacity-intro h2, +.lora-placement-grid h2, +.lora-run-head h2 { + margin: 0; + font-family: var(--site-font-body); + font-size: clamp(1.65rem, 3.5vw, 2.65rem); + font-weight: 650; + letter-spacing: -0.045em; + line-height: 1.07; +} + +.lora-two-column p, +.lora-section-head p, +.lora-capacity-intro p, +.lora-placement-grid p, +.lora-run-head > p:last-child { + margin: 0 0 1rem; + color: var(--lora-secondary); + font-size: 1rem; + line-height: 1.72; +} + +.lora-primer-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-top: 1px solid var(--lora-ink); + border-bottom: 1px solid var(--lora-ink); +} + +.lora-primer-grid article { + padding: 1.25rem; +} + +.lora-primer-grid article + article { + border-left: 1px solid var(--lora-rule); +} + +.lora-primer-grid span, +.lora-setting-grid article > span, +.lora-decision-columns article > span, +.lora-application-note > span { + display: block; + margin-bottom: 1.5rem; + color: var(--lora-accent); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.11em; +} + +.lora-primer-grid strong { + display: block; + font-size: 1rem; + line-height: 1.35; +} + +.lora-primer-grid p { + margin: 0.7rem 0 0; + color: var(--lora-secondary); + font-size: 0.84rem; + line-height: 1.65; +} + +.lora-question { + display: grid; + grid-template-columns: 11rem 1fr; + gap: 2rem; + margin-top: 2rem; + border: 1px solid var(--lora-accent); + padding: 1.4rem; +} + +.lora-question > span { + color: var(--lora-accent); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.12em; +} + +.lora-question p { + margin: 0; + font-size: 1rem; + font-weight: 550; + line-height: 1.55; +} + +.lora-equation-card { border: 1px solid var(--lora-rule); } + +.lora-equation { + margin: 0; + border-bottom: 1px solid var(--lora-rule); + padding: 1.4rem; + font-family: Georgia, serif; + font-size: clamp(1.8rem, 4vw, 2.8rem); + line-height: 1; + white-space: nowrap; +} + +.lora-equation span { color: var(--lora-accent); font-size: 0.68em; } +.lora-equation-card dl { margin: 0; padding: 0 1.4rem; } + +.lora-equation-card dl > div { + display: grid; + grid-template-columns: 4rem 1fr; + border-bottom: 1px solid var(--lora-rule); + padding: 0.65rem 0; +} + +.lora-equation-card dl > div:last-child { border-bottom: 0; } +.lora-equation-card dt { font-family: Georgia, serif; font-weight: 700; } +.lora-equation-card dd { margin: 0; color: var(--lora-muted); font-size: 0.76rem; } + +.lora-rank-lab { + margin-top: 3rem; + border: 1px solid var(--lora-rule); + padding: clamp(1.2rem, 3vw, 2rem); +} + +.lora-rank-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 2rem; } +.lora-rank-head h3 { margin: 0; font-size: 1.2rem; } + +.lora-rank-head output { + display: flex; + flex-direction: column; + align-items: flex-end; + color: var(--lora-accent); + font-family: var(--site-font-display); + font-size: 2.6rem; + line-height: 0.8; +} + +.lora-rank-head output small { + margin-top: 0.5rem; + color: var(--lora-muted); + font-family: var(--site-font-body); + font-size: 0.52rem; + font-weight: 700; + letter-spacing: 0.12em; +} + +.lora-slider-label { + display: grid; + grid-template-columns: 7rem 1fr; + gap: 1rem; + align-items: center; + margin: 2rem 0 1.5rem; + color: var(--lora-muted); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; +} + +.lora-slider-label input { width: 100%; accent-color: var(--lora-accent); } + +.lora-rank-visual { + position: relative; + height: 5.5rem; + border: 1px solid var(--lora-rule); + background-image: + linear-gradient(to right, var(--lora-rule) 1px, transparent 1px), + linear-gradient(to bottom, var(--lora-rule) 1px, transparent 1px); + background-size: 14px 14px; + overflow: hidden; +} + +.lora-rank-base, +.lora-rank-adapter { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: 0.75rem; +} + +.lora-rank-base { align-items: flex-end; color: var(--lora-muted); } +.lora-rank-adapter { + right: auto; + width: max(1px, var(--adapter-share, 1.56%)); + min-width: 0; + padding: 0; + background: color-mix(in srgb, var(--lora-accent) 88%, white); + transition: width 120ms ease; +} + +.lora-rank-visual span { font-family: "SF Mono", Menlo, monospace; font-size: 0.78rem; font-weight: 700; } +.lora-rank-visual small { margin-top: 0.25rem; font-size: 0.5rem; font-weight: 700; letter-spacing: 0.08em; } + +.lora-rank-readout { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid var(--lora-rule); } +.lora-rank-readout p { margin: 0; padding: 1rem 0; } +.lora-rank-readout p + p { border-left: 1px solid var(--lora-rule); padding-left: 1rem; } +.lora-rank-readout strong, +.lora-rank-readout span { display: block; } +.lora-rank-readout strong { font-size: 1rem; } +.lora-rank-readout span { margin-top: 0.25rem; color: var(--lora-muted); font-size: 0.65rem; } +.lora-rank-readout b { color: var(--lora-ink); font-weight: 700; } + +.lora-caption { margin: 1rem 0 0; color: var(--lora-muted); font-size: 0.7rem; line-height: 1.55; } + +.lora-dark { + --lora-accent: #c7aace; + margin-right: calc(50% - 50vw); + margin-left: calc(50% - 50vw); + padding-right: max(1.25rem, calc((100vw - 920px) / 2)); + padding-left: max(1.25rem, calc((100vw - 920px) / 2)); + color: #efedf0; + background: #121113; +} + +.lora-section-label-dark { color: #8f8992; } +.lora-dark .lora-capacity-intro p { color: #bbb5bd; } + +.lora-findings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + margin-top: 3rem; + border-top: 1px solid #4b464e; + border-left: 1px solid #4b464e; +} + +.lora-findings-grid article { + min-width: 0; + border-right: 1px solid #4b464e; + border-bottom: 1px solid #4b464e; + padding: 1.4rem; +} + +.lora-findings-grid article > span { + color: #c7aace; + font-size: 0.62rem; + font-weight: 700; +} + +.lora-findings-grid h3 { + margin: 1.5rem 0 0.7rem; + color: #efedf0; + font-size: 1.05rem; +} + +.lora-findings-grid p { + margin: 0; + color: #bbb5bd; + font-size: 0.88rem; + line-height: 1.65; +} + +.lora-findings-grid strong { + display: block; + margin-top: 1rem; + color: #efedf0; + font-size: 0.8rem; + line-height: 1.5; +} + +.lora-signal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 3rem; } +.lora-signal-grid article { border: 1px solid #38343a; padding: 1.4rem; } +.lora-signal-grid h3 { margin: 0 0 0.8rem; font-size: 1.2rem; } +.lora-signal-grid article > p:last-child { margin: 0; color: #aaa4ad; font-size: 0.88rem; line-height: 1.65; } + +.lora-signal-grid-light article { border-color: var(--lora-rule); } +.lora-signal-grid-light article > p:last-child { color: var(--lora-secondary); } +.lora-signal-grid-light .lora-bandwidth { border-color: var(--lora-rule); } +.lora-capacity-answer { margin-top: 1rem; } + +.lora-bandwidth { + display: flex; + align-items: flex-end; + gap: 4px; + height: 4.4rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid #514b54; + padding: 0 0.3rem; +} + +.lora-bandwidth i { display: block; width: 100%; background: #c7aace; } +.lora-bandwidth-dense i { height: calc(26% + ((var(--i) * 19) % 62) * 1%); opacity: calc(0.45 + var(--i) * 0.025); } +.lora-bandwidth-sparse { justify-content: space-around; } +.lora-bandwidth-sparse i { width: 12%; height: 18%; background: #83a99a; } +.lora-bandwidth-sparse i:nth-child(2) { height: 72%; } +.lora-bandwidth-sparse i:nth-child(3) { height: 34%; } + +.lora-caveat { + display: grid; + grid-template-columns: 12rem 1fr; + gap: 2rem; + margin-top: 1rem; + border: 1px solid #c7aace; + padding: 1.3rem; +} + +.lora-caveat p { margin: 0; color: #bbb5bd; font-size: 0.78rem; line-height: 1.6; } + +.lora-technical-note { + margin-top: 1.5rem; + border: 1px solid var(--lora-rule); +} + +.lora-technical-note summary { + cursor: pointer; + padding: 1rem 1.2rem; + color: var(--lora-ink); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.07em; +} + +.lora-technical-note p { + margin: 0; + border-top: 1px solid var(--lora-rule); + padding: 1.2rem; + color: var(--lora-secondary); + font-size: 0.86rem; + line-height: 1.7; +} + +.lora-technical-note-dark { border-color: #4b464e; } +.lora-technical-note-dark summary { color: #efedf0; } +.lora-technical-note-dark p { border-color: #4b464e; color: #bbb5bd; } + +.lora-section-head { margin-bottom: 3rem; } +.lora-evidence-table { border-top: 1px solid var(--lora-ink); } + +.lora-evidence-row { + display: grid; + grid-template-columns: 0.85fr 1.3fr 1.25fr 0.7fr; + border-bottom: 1px solid var(--lora-rule); +} + +.lora-evidence-row > * { min-width: 0; padding: 1rem 0.8rem; } +.lora-evidence-row > * + * { border-left: 1px solid var(--lora-rule); } +.lora-evidence-header { color: var(--lora-muted); font-size: 0.55rem; font-weight: 700; letter-spacing: 0.1em; } +.lora-evidence-row strong { font-size: 0.8rem; } +.lora-evidence-row span { color: var(--lora-secondary); font-size: 0.72rem; line-height: 1.5; } +.lora-evidence-row em { color: var(--lora-accent); font-size: 0.6rem; font-style: normal; font-weight: 700; letter-spacing: 0.07em; } + +.lora-placement-grid { display: grid; grid-template-columns: minmax(17rem, 0.85fr) minmax(0, 1.15fr); gap: clamp(2rem, 6vw, 5rem); align-items: start; } +.lora-block-map { position: relative; border: 1px solid var(--lora-rule); padding: 1rem; } +.lora-block-map > div { display: flex; justify-content: space-between; align-items: center; min-height: 5.2rem; border: 1px solid var(--lora-rule); padding: 1rem; } +.lora-block-map > div + div { margin-top: 0.7rem; } +.lora-block-map span { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.09em; } +.lora-block-map small { color: var(--lora-muted); font-size: 0.55rem; } +.lora-block-mlp { min-height: 9rem !important; border-color: var(--lora-accent) !important; background: color-mix(in srgb, var(--lora-accent) 13%, transparent); } +.lora-block-mlp span { color: var(--lora-accent); } +.lora-block-residual { min-height: 2.8rem !important; background: var(--site-code-bg); } + +.lora-placement-fact { + display: grid; + grid-template-columns: 1fr 1fr; + margin-top: 2rem; + border-top: 1px solid var(--lora-rule); + border-bottom: 1px solid var(--lora-rule); +} + +.lora-placement-fact > * { padding: 0.8rem; } +.lora-placement-fact > *:nth-child(even) { border-left: 1px solid var(--lora-rule); } +.lora-placement-fact span { color: var(--lora-muted); font-size: 0.55rem; font-weight: 700; letter-spacing: 0.08em; } +.lora-placement-fact strong { font-family: "SF Mono", Menlo, monospace; font-size: 0.72rem; } + +.lora-setting-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-top: 3rem; + border-top: 1px solid var(--lora-ink); + border-bottom: 1px solid var(--lora-ink); +} + +.lora-setting-grid article { + padding: 1.25rem; +} + +.lora-setting-grid article + article { + border-left: 1px solid var(--lora-rule); +} + +.lora-setting-grid h3 { + margin: 0 0 1rem; + font-size: 1.15rem; +} + +.lora-setting-grid p { + margin: 0 0 0.9rem; + color: var(--lora-secondary); + font-size: 0.84rem; + line-height: 1.65; +} + +.lora-setting-grid p:last-child { margin-bottom: 0; } +.lora-setting-grid p strong { color: var(--lora-ink); } + +.lora-optimization-grid { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--lora-ink); border-bottom: 1px solid var(--lora-ink); } +.lora-optimization-grid article { padding: 1.2rem; } +.lora-optimization-grid article + article { border-left: 1px solid var(--lora-rule); } +.lora-big-number { display: block; min-height: 4rem; color: var(--lora-accent); font-family: var(--site-font-display); font-size: clamp(2.4rem, 5vw, 4rem); line-height: 1; } +.lora-optimization-grid h3 { margin: 1.2rem 0 0.6rem; font-size: 1rem; } +.lora-optimization-grid p { margin: 0; color: var(--lora-secondary); font-size: 0.75rem; line-height: 1.6; } + +.lora-batch-strip { display: grid; grid-template-columns: 1fr 1fr 1.35fr; gap: 1rem; margin-top: 2.5rem; } +.lora-batch-strip > div { border: 1px solid var(--lora-rule); padding: 1rem; } +.lora-batch-strip > div > span { color: var(--lora-muted); font-size: 0.57rem; font-weight: 700; letter-spacing: 0.09em; } +.lora-batch-strip small { color: var(--lora-muted); font-size: 0.62rem; } +.lora-batch-strip > p { align-self: end; margin: 0; color: var(--lora-secondary); font-size: 0.8rem; line-height: 1.6; } + +.lora-loss-lines { position: relative; height: 4rem; margin: 0.7rem 0; overflow: hidden; } +.lora-loss-lines i { position: absolute; inset: 0; border-top: 2px solid var(--lora-accent); transform: translateY(42%) skewY(-8deg); } +.lora-loss-lines i + i { border-color: var(--lora-green); transform: translateY(54%) skewY(-8deg); } +.lora-loss-apart i + i { transform: translateY(82%) skewY(-4deg); } + +.lora-flop-grid { display: grid; grid-template-columns: 1fr auto 1fr; gap: 1.5rem; align-items: center; } +.lora-flop-card { border: 1px solid #38343a; padding: 1.4rem; } +.lora-flop-card-accent { border-color: #c7aace; } +.lora-flop-card strong { display: block; margin: 1.8rem 0; color: #efedf0; font-family: Georgia, serif; font-size: clamp(2.2rem, 5vw, 4rem); font-weight: 400; } +.lora-flop-card-accent strong { color: #c7aace; } +.lora-flop-card ul { margin: 0; padding: 0; list-style: none; color: #aaa4ad; font-size: 0.72rem; line-height: 1.9; } +.lora-versus { color: #8f8992; font-size: 0.57rem; font-weight: 700; letter-spacing: 0.1em; } +.lora-compute-statement { max-width: 47rem; margin: 3rem 0 0 auto; font-size: clamp(1.4rem, 3vw, 2.1rem); line-height: 1.3; } +.lora-compute-statement strong { color: #c7aace; font-family: Georgia, serif; font-size: 1.3em; font-weight: 400; } + +.lora-savings-notes { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 3rem; border-top: 1px solid #38343a; border-bottom: 1px solid #38343a; } +.lora-savings-notes p { margin: 0; padding: 1rem; color: #aaa4ad; font-size: 0.7rem; line-height: 1.55; } +.lora-savings-notes p + p { border-left: 1px solid #38343a; } +.lora-savings-notes span { display: block; margin-bottom: 0.65rem; color: #efedf0; font-size: 0.55rem; font-weight: 700; letter-spacing: 0.08em; } + +.lora-benefit-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-top: 3rem; + border-top: 1px solid #4b464e; + border-bottom: 1px solid #4b464e; +} + +.lora-benefit-grid article { + padding: 1.3rem; +} + +.lora-benefit-grid article + article { + border-left: 1px solid #4b464e; +} + +.lora-benefit-grid article > span { + color: #c7aace; + font-size: 0.62rem; + font-weight: 700; +} + +.lora-benefit-grid h3 { + margin: 1.8rem 0 0.7rem; + color: #efedf0; + font-size: 1rem; +} + +.lora-benefit-grid p { + margin: 0; + color: #bbb5bd; + font-size: 0.85rem; + line-height: 1.65; +} + +.lora-config-table { border-top: 1px solid var(--lora-ink); } +.lora-config-header, +.lora-config-row { display: grid; grid-template-columns: 0.9fr 1fr 1.2fr 1.45fr; } +.lora-config-header { color: var(--lora-muted); font-size: 0.54rem; font-weight: 700; letter-spacing: 0.09em; } +.lora-config-header > *, +.lora-config-row > * { padding: 0.9rem 0.75rem; border-bottom: 1px solid var(--lora-rule); } +.lora-config-header > * + *, +.lora-config-row > * + * { border-left: 1px solid var(--lora-rule); } +.lora-config-row strong { font-size: 0.75rem; } +.lora-config-row span { color: var(--lora-secondary); font-size: 0.69rem; line-height: 1.5; } +.lora-config-row span:nth-child(3) { color: var(--lora-ink); font-weight: 600; } + +.lora-decision-rule { display: grid; grid-template-columns: 10rem 1fr; gap: 2rem; margin-top: 2rem; border: 1px solid var(--lora-accent); padding: 1.3rem; } +.lora-decision-rule p { margin: 0; font-size: 1rem; line-height: 1.6; } + +.lora-decision-columns { + display: grid; + grid-template-columns: 1fr 1fr; + margin-top: 3rem; + border: 1px solid var(--lora-rule); +} + +.lora-decision-columns article { + padding: 1.4rem; +} + +.lora-decision-columns article + article { + border-left: 1px solid var(--lora-rule); +} + +.lora-decision-columns ul { + margin: 0; + padding-left: 1.15rem; + color: var(--lora-secondary); + font-size: 0.88rem; + line-height: 1.65; +} + +.lora-decision-columns li + li { margin-top: 0.7rem; } + +.lora-run-head { max-width: 47rem; } +.lora-run-head h2 { margin-bottom: 1.2rem; } +.lora-run-steps { margin: 3rem 0 0; padding: 0; border-top: 1px solid var(--lora-ink); list-style: none; } +.lora-run-steps li { display: grid; grid-template-columns: 4rem 1fr; border-bottom: 1px solid var(--lora-rule); padding: 1rem 0; } +.lora-run-steps li > span { color: var(--lora-accent); font-size: 0.62rem; font-weight: 700; } +.lora-run-steps strong { font-size: 0.86rem; } +.lora-run-steps p { margin: 0.35rem 0 0; color: var(--lora-secondary); font-size: 0.84rem; line-height: 1.6; } +.lora-run-grid { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 2.5rem; border-top: 1px solid var(--lora-rule); border-bottom: 1px solid var(--lora-rule); } +.lora-run-grid article { padding: 1rem; } +.lora-run-grid article + article { border-left: 1px solid var(--lora-rule); } +.lora-run-grid span { display: block; margin-bottom: 1.5rem; color: var(--lora-muted); font-size: 0.55rem; font-weight: 700; letter-spacing: 0.08em; } +.lora-run-grid strong { font-family: "SF Mono", Menlo, monospace; font-size: 0.72rem; } +.lora-run-grid p { margin: 0.6rem 0 0; color: var(--lora-secondary); font-size: 0.7rem; line-height: 1.5; } + +.lora-application-note { + display: grid; + grid-template-columns: 13rem 1fr; + gap: 2rem; + margin-top: 2rem; + border: 1px solid var(--lora-accent); + padding: 1.4rem; +} + +.lora-application-note p { + margin: 0; + color: var(--lora-secondary); + font-size: 0.88rem; + line-height: 1.7; +} + +.lora-limits-intro { + max-width: 48rem; + margin: 0 0 2.5rem; + color: var(--lora-secondary); + font-size: 1rem; + line-height: 1.7; +} + +.lora-limits-grid { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--lora-rule); border-bottom: 1px solid var(--lora-rule); } +.lora-limits-grid article { padding: 1rem; } +.lora-limits-grid article + article { border-left: 1px solid var(--lora-rule); } +.lora-limits-grid article > span { color: var(--lora-accent); font-size: 0.58rem; font-weight: 700; } +.lora-limits-grid h3 { margin: 2rem 0 0.6rem; font-size: 0.9rem; } +.lora-limits-grid p { margin: 0; color: var(--lora-secondary); font-size: 0.8rem; line-height: 1.6; } + +.lora-footer { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; padding: 3.5rem 0 5rem; } +.lora-footer > div:first-child { display: flex; flex-direction: column; align-items: flex-start; gap: 0.6rem; } +.lora-footer > div:first-child > span { margin-bottom: 0.5rem; color: var(--lora-muted); font-size: 0.56rem; font-weight: 700; letter-spacing: 0.1em; } +.lora-footer a { font-size: 0.68rem; font-weight: 650; text-decoration: none; } +.lora-footer a:hover { color: var(--lora-accent); } +.lora-footer-note { align-self: end; border-left: 1px solid var(--lora-accent); padding-left: 1.5rem; } +.lora-footer-note strong, +.lora-footer-note span { display: block; } +.lora-footer-note strong { font-family: var(--site-font-display); font-size: 1.5rem; font-weight: 400; } +.lora-footer-note span { margin-top: 0.35rem; color: var(--lora-muted); font-size: 0.58rem; font-weight: 700; letter-spacing: 0.1em; } + +@media (max-width: 760px) { + .lora-hero, + .lora-two-column, + .lora-section-head, + .lora-capacity-intro, + .lora-placement-grid { grid-template-columns: 1fr; } + .lora-factorization { width: 100%; } + .lora-factor-row { justify-content: flex-start; } + .lora-thesis { grid-template-columns: 1fr; gap: 0.7rem; } + .lora-signal-grid { grid-template-columns: 1fr; } + .lora-primer-grid, + .lora-setting-grid, + .lora-benefit-grid { grid-template-columns: 1fr; } + .lora-primer-grid article + article, + .lora-setting-grid article + article, + .lora-benefit-grid article + article { border-top: 1px solid var(--lora-rule); border-left: 0; } + .lora-benefit-grid article + article { border-top-color: #4b464e; } + .lora-topline { grid-template-columns: 1fr 1fr; } + .lora-topline li:nth-child(3) { border-top: 1px solid var(--lora-rule); border-left: 0; } + .lora-topline li:nth-child(4) { border-top: 1px solid var(--lora-rule); } + .lora-caveat { grid-template-columns: 1fr; gap: 0.8rem; } + .lora-evidence-header, + .lora-config-header { display: none; } + .lora-evidence-row, + .lora-config-row { grid-template-columns: 1fr 1fr; } + .lora-evidence-row > * + *, + .lora-config-row > * + * { border-left: 0; } + .lora-evidence-row > *:nth-child(even), + .lora-config-row > *:nth-child(even) { border-left: 1px solid var(--lora-rule); } + .lora-optimization-grid, + .lora-run-grid { grid-template-columns: 1fr; } + .lora-optimization-grid article + article, + .lora-run-grid article + article { border-top: 1px solid var(--lora-rule); border-left: 0; } + .lora-batch-strip { grid-template-columns: 1fr 1fr; } + .lora-batch-strip > p { grid-column: 1 / -1; } + .lora-savings-notes { grid-template-columns: 1fr; } + .lora-savings-notes p + p { border-top: 1px solid #38343a; border-left: 0; } + .lora-limits-grid { grid-template-columns: 1fr 1fr; } + .lora-limits-grid article:nth-child(3) { border-top: 1px solid var(--lora-rule); border-left: 0; } + .lora-limits-grid article:nth-child(4) { border-top: 1px solid var(--lora-rule); } +} + +@media (max-width: 480px) { + .lora-page { padding: 0 1rem; } + .lora-nav { align-items: flex-start; padding: 1rem 0; } + .lora-nav > div { flex-direction: column; align-items: flex-end; gap: 0.3rem; } + .lora-hero { padding-top: 3rem; } + .lora-hero h1 { font-size: clamp(3rem, 17vw, 4.6rem); } + .lora-factor-row { gap: 0.55rem; transform-origin: left center; transform: scale(0.88); width: 113%; } + .lora-matrix-base { width: 7rem; } + .lora-section-label { grid-template-columns: 2rem 1fr; } + .lora-rank-head { gap: 1rem; } + .lora-slider-label { grid-template-columns: 1fr; } + .lora-dark { padding-right: 1rem; padding-left: 1rem; } + .lora-rank-readout { grid-template-columns: 1fr; } + .lora-rank-readout p + p { border-top: 1px solid var(--lora-rule); border-left: 0; padding-left: 0; } + .lora-evidence-row, + .lora-config-row { grid-template-columns: 1fr; margin-bottom: 1rem; border: 1px solid var(--lora-rule); } + .lora-evidence-row > *, + .lora-config-row > * { border-bottom: 1px solid var(--lora-rule); } + .lora-evidence-row > *:nth-child(even), + .lora-config-row > *:nth-child(even) { border-left: 0; } + .lora-evidence-row > *:last-child, + .lora-config-row > *:last-child { border-bottom: 0; } + .lora-placement-fact { grid-template-columns: 1fr; } + .lora-placement-fact > *:nth-child(even) { border-left: 0; } + .lora-placement-fact > * + * { border-top: 1px solid var(--lora-rule); } + .lora-batch-strip, + .lora-flop-grid { grid-template-columns: 1fr; } + .lora-question, + .lora-application-note, + .lora-decision-columns { grid-template-columns: 1fr; } + .lora-question, + .lora-application-note { gap: 0.8rem; } + .lora-decision-columns article + article { border-top: 1px solid var(--lora-rule); border-left: 0; } + .lora-findings-grid { grid-template-columns: 1fr; } + .lora-versus { text-align: center; } + .lora-decision-rule { grid-template-columns: 1fr; gap: 0.8rem; } + .lora-run-steps li { grid-template-columns: 2.5rem 1fr; } + .lora-limits-grid { grid-template-columns: 1fr; } + .lora-limits-grid article + article { border-top: 1px solid var(--lora-rule); border-left: 0; } + .lora-footer { grid-template-columns: 1fr; } + .lora-evidence-row span, + .lora-config-row span, + .lora-run-steps p, + .lora-run-grid p, + .lora-limits-grid p, + .lora-footer a { font-size: 0.76rem; } +} + +@media (max-width: 360px) { + .lora-topline { grid-template-columns: 1fr; } + .lora-topline li + li { border-top: 1px solid var(--lora-rule); border-left: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .lora-rank-adapter { transition: none; } +} diff --git a/public/lora-artifact.js b/public/lora-artifact.js new file mode 100644 index 0000000..6da70bc --- /dev/null +++ b/public/lora-artifact.js @@ -0,0 +1,26 @@ +(() => { + const lab = document.querySelector("[data-rank-lab]"); + if (!lab) return; + + const slider = lab.querySelector("input[type='range']"); + const rankValue = lab.querySelector("[data-rank-value]"); + const adapterParams = lab.querySelector("[data-adapter-params]"); + const percent = lab.querySelector("[data-percent]"); + const adapterVisual = lab.querySelector(".lora-rank-adapter"); + const dimension = 4096; + const fullParams = dimension * dimension; + + const render = () => { + const rank = Number(slider.value); + const params = rank * (dimension + dimension); + const share = (params / fullParams) * 100; + + rankValue.textContent = String(rank); + adapterParams.textContent = params.toLocaleString("en-US"); + percent.textContent = `${share < 0.1 ? share.toFixed(3) : share.toFixed(2)}%`; + adapterVisual.style.setProperty("--adapter-share", `${share}%`); + }; + + slider.addEventListener("input", render); + render(); +})(); diff --git a/public/lora-topology.css b/public/lora-topology.css new file mode 100644 index 0000000..c48e009 --- /dev/null +++ b/public/lora-topology.css @@ -0,0 +1,831 @@ +.lora-article-shell { + --lora-accent: #755d83; + --lora-warm: #a7554a; + --lora-green: #4f7c69; +} + +:root[data-theme="dark"] .lora-article-shell { + --lora-accent: #c1a7cd; + --lora-warm: #e28f83; + --lora-green: #91b9a6; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .lora-article-shell { + --lora-accent: #c1a7cd; + --lora-warm: #e28f83; + --lora-green: #91b9a6; + } +} + +.lora-article-shell *, +.lora-article-shell *::before, +.lora-article-shell *::after { + box-sizing: border-box; +} + +.lora-article { + margin-top: 1rem; +} + +.lora-article-header { + padding: clamp(2.8rem, 9vw, 5.5rem) 0 clamp(2.5rem, 7vw, 4rem); + border-bottom: 1px solid var(--site-border); +} + +.lora-backlink { + color: var(--site-text-muted); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-decoration: none; + text-transform: uppercase; +} + +.lora-backlink:hover { + color: var(--lora-accent); +} + +.lora-article-header h1 { + max-width: 35rem; + margin: 1.25rem 0 0; + font-size: clamp(3.15rem, 11vw, 5.4rem); + letter-spacing: 0.02em; + line-height: 0.9; +} + +.lora-deck { + max-width: 35rem; + margin: 1.6rem 0 0; + color: var(--site-text-secondary); + font-size: clamp(1.05rem, 2.5vw, 1.25rem); + line-height: 1.6; +} + +.lora-meta { + display: flex; + flex-wrap: wrap; + gap: 0.55rem 1.25rem; + margin-top: 2rem; + color: var(--site-text-muted); + font-size: 0.7rem; +} + +.lora-meta span + span::before { + content: "·"; + margin-right: 1.25rem; +} + +.lora-prose { + font-size: 1rem; + line-height: 1.75; +} + +.lora-prose section { + padding: clamp(3rem, 8vw, 4.8rem) 0; + border-bottom: 1px solid var(--site-border); +} + +.lora-prose h2, +.lora-sources h2 { + margin: 0 0 1.2rem; + font-family: var(--site-font-body); + font-size: clamp(1.45rem, 4vw, 1.85rem); + font-weight: 700; + letter-spacing: -0.025em; + line-height: 1.2; +} + +.lora-prose h3 { + margin: 0; + font-size: 1rem; + line-height: 1.4; +} + +.lora-prose p { + margin: 1rem 0; +} + +.lora-prose strong { + color: var(--site-text); +} + +.lora-lede { + margin-top: 0 !important; + color: var(--site-text); + font-size: clamp(1.22rem, 3.5vw, 1.48rem); + letter-spacing: -0.02em; + line-height: 1.5; +} + +.lora-direction-figure, +.topology-instrument, +.lora-cipher-figure, +.lora-run-grid { + margin: 2.2rem 0; +} + +.lora-direction-figure { + display: grid; + grid-template-columns: 1fr 3.2rem 1fr; + align-items: center; + min-height: 13rem; + border-top: 1px solid var(--site-border); + border-bottom: 1px solid var(--site-border); +} + +.lora-direction-group { + display: grid; + align-content: center; + gap: 0.8rem; + min-height: 9rem; + overflow: hidden; +} + +.lora-direction-group span { + display: block; + width: 78%; + height: 2px; + margin-left: auto; + background: var(--lora-accent); + opacity: 0.72; + transform: rotate(calc(var(--offset) * 1.4deg)); + transform-origin: right center; +} + +.lora-direction-group-many { + gap: 0.45rem; +} + +.lora-direction-group-many span { + width: 72%; + margin-right: auto; + margin-left: 0; + background: var(--lora-warm); + transform: rotate(calc(var(--offset) * 3deg)); + transform-origin: left center; +} + +.lora-rank-gate { + display: grid; + place-items: center; + width: 3.2rem; + height: 8rem; + border: 1px solid var(--lora-accent); + color: var(--lora-accent); + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.13em; + writing-mode: vertical-rl; + transform: rotate(180deg); +} + +.lora-direction-figure figcaption, +.topology-instrument figcaption, +.lora-cipher-figure figcaption, +.lora-run-grid figcaption { + grid-column: 1 / -1; + margin: 0; + border-top: 1px solid var(--site-border); + padding: 0.8rem 0; + color: var(--site-text-muted); + font-size: 0.72rem; + line-height: 1.55; +} + +.lora-definitions { + margin: 2rem 0; + border-top: 1px solid var(--site-border); +} + +.lora-definitions > div { + display: grid; + grid-template-columns: 8.2rem 1fr; + gap: 1.4rem; + border-bottom: 1px solid var(--site-border); + padding: 1rem 0; +} + +.lora-definitions dt { + color: var(--lora-accent); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.03em; +} + +.lora-definitions dd { + margin: 0; + color: var(--site-text-secondary); + font-size: 0.88rem; + line-height: 1.6; +} + +.lora-note, +.lora-falsification { + margin: 2rem 0 0; + border-left: 3px solid var(--lora-accent); + padding: 0.15rem 0 0.15rem 1.2rem; +} + +.lora-note > strong, +.lora-falsification > strong { + color: var(--lora-accent); + font-size: 0.75rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.lora-note p, +.lora-falsification p { + margin: 0.6rem 0 0; + color: var(--site-text-secondary); + font-size: 0.9rem; + line-height: 1.65; +} + +.topology-instrument { + border-top: 1px solid var(--site-border); +} + +.topology-controls { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-bottom: 1px solid var(--site-border); +} + +.topology-controls button { + display: flex; + justify-content: space-between; + gap: 0.5rem; + border: 0; + padding: 0.8rem; + background: transparent; + color: var(--site-text-muted); + font: inherit; + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; +} + +.topology-controls button + button { + border-left: 1px solid var(--site-border); +} + +.topology-controls button span { + color: var(--lora-accent); + font-variant-numeric: tabular-nums; +} + +.topology-controls button:hover, +.topology-controls button:focus-visible { + color: var(--site-text); +} + +.topology-controls button:focus-visible { + outline: 2px solid var(--lora-accent); + outline-offset: -3px; +} + +.topology-controls button.is-active { + background: color-mix(in srgb, var(--lora-accent) 14%, var(--site-bg)); + color: var(--site-text); +} + +.topology-world-field { + display: grid; + grid-template-columns: repeat(16, 1fr); + gap: clamp(3px, 0.8vw, 6px); + padding: clamp(1rem, 4vw, 1.6rem); + background-image: + linear-gradient(to right, color-mix(in srgb, var(--site-border) 55%, transparent) 1px, transparent 1px), + linear-gradient(to bottom, color-mix(in srgb, var(--site-border) 55%, transparent) 1px, transparent 1px); + background-size: 18px 18px; +} + +.topology-world-field i { + display: block; + aspect-ratio: 1; + border: 1px solid color-mix(in srgb, var(--lora-accent) 70%, var(--site-bg)); + background: color-mix(in srgb, var(--lora-accent) 38%, transparent); + transition: border-radius 220ms ease, background 220ms ease, transform 220ms ease; +} + +.topology-instrument[data-mode="coherent"] .topology-world-field i { + border-radius: 50%; + transform: scale(0.76); +} + +.topology-instrument[data-mode="clustered"] .topology-world-field i { + border-color: hsl(calc(255 + var(--cluster) * 17) 35% 57%); + background: hsl(calc(255 + var(--cluster) * 17) 35% 47% / 0.42); + border-radius: calc(var(--cluster) * 4%); + transform: rotate(calc(var(--cluster) * 5deg)) scale(0.82); +} + +.topology-instrument[data-mode="fragmented"] .topology-world-field i { + border-color: hsl(calc(210 + var(--cell) * 17) 42% 56%); + background: hsl(calc(210 + var(--cell) * 17) 38% 48% / 0.44); + border-radius: calc(var(--cell) * 0.4%); + transform: rotate(calc(var(--cell) * 7deg)) scale(calc(0.68 + var(--cell) * 0.002)); +} + +.topology-readout { + display: grid; + grid-template-columns: 0.75fr 1.4fr 1fr; + border-top: 1px solid var(--site-border); + border-bottom: 1px solid var(--site-border); +} + +.topology-readout p { + min-width: 0; + margin: 0; + padding: 0.8rem; +} + +.topology-readout p + p { + border-left: 1px solid var(--site-border); +} + +.topology-readout span { + display: block; + margin-bottom: 0.3rem; + color: var(--site-text-muted); + font-size: 0.55rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.topology-readout strong { + display: block; + font-size: 0.76rem; + line-height: 1.35; + text-transform: lowercase; +} + +.topology-summary { + margin: 1rem 0 !important; + color: var(--site-text-secondary); + font-size: 0.86rem; + line-height: 1.6; +} + +.topology-frontier { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.8rem; + align-items: center; + padding-bottom: 1.1rem; + color: var(--site-text-muted); + font-size: 0.62rem; +} + +.topology-frontier i { + position: relative; + display: block; + height: 1px; + background: var(--site-border); +} + +.topology-frontier b { + --frontier-position: 8%; + position: absolute; + top: 50%; + left: var(--frontier-position); + width: 0.65rem; + aspect-ratio: 1; + border-radius: 50%; + background: var(--lora-warm); + transform: translate(-50%, -50%); + transition: left 220ms ease; +} + +.lora-prediction-list { + margin: 2rem 0; + border-top: 1px solid var(--site-border); +} + +.lora-prediction-list article { + display: grid; + grid-template-columns: 2rem minmax(0, 1fr) 8rem; + gap: 0.85rem; + align-items: start; + border-bottom: 1px solid var(--site-border); + padding: 1rem 0; +} + +.lora-prediction-number { + color: var(--lora-accent); + font-size: 0.68rem; + font-weight: 700; +} + +.lora-prediction-list h3 { + margin-bottom: 0.25rem; +} + +.lora-prediction-list p, +.lora-prediction-list small { + display: block; + margin: 0; + color: var(--site-text-secondary); + font-size: 0.78rem; + line-height: 1.55; +} + +.lora-prediction-list small { + margin-top: 0.35rem; + color: var(--site-text-muted); +} + +.lora-prediction-list article > strong { + color: var(--lora-accent); + font-size: 0.7rem; + line-height: 1.45; + text-align: right; +} + +.lora-comparison { + margin-top: 2.5rem; + border-top: 2px solid var(--site-text); + border-bottom: 1px solid var(--site-text); + padding: 1.3rem 0; +} + +.lora-comparison h3 { + color: var(--lora-warm); + font-size: 0.78rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.lora-comparison > div { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +.lora-comparison > div p { + color: var(--site-text-secondary); + font-size: 0.84rem; + line-height: 1.65; +} + +.lora-comparison-rule { + margin: 0.7rem 0 0 !important; + border-top: 1px solid var(--site-border); + padding-top: 1rem; + color: var(--site-text-muted); + font-size: 0.78rem; +} + +.lora-cipher-figure > div { + display: grid; + grid-template-columns: auto 1fr auto auto 1fr auto auto 1fr; + gap: 0.65rem; + align-items: baseline; + border-top: 1px solid var(--site-border); + border-bottom: 1px solid var(--site-border); + padding: 1.1rem 0; +} + +.lora-cipher-figure span { + color: var(--site-text-muted); + font-size: 0.58rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.lora-cipher-figure strong { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: clamp(0.82rem, 2.6vw, 1.05rem); + font-weight: 500; + letter-spacing: 0.06em; +} + +.lora-cipher-figure strong:last-child { + color: var(--lora-warm); +} + +.lora-cipher-figure b { + color: var(--site-text-muted); + font-weight: 400; +} + +.lora-experiment-steps { + margin: 2rem 0; + padding: 0; + border-top: 1px solid var(--site-border); + list-style: none; +} + +.lora-experiment-steps li { + display: grid; + grid-template-columns: 2rem 1fr; + gap: 0.8rem; + border-bottom: 1px solid var(--site-border); + padding: 1rem 0; +} + +.lora-experiment-steps li > span { + color: var(--lora-accent); + font-size: 0.68rem; + font-weight: 700; +} + +.lora-experiment-steps h3 { + margin-bottom: 0.35rem; +} + +.lora-experiment-steps p { + margin: 0; + color: var(--site-text-secondary); + font-size: 0.84rem; + line-height: 1.62; +} + +.lora-run-grid { + display: grid; + grid-template-columns: repeat(9, auto); + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--site-border); + padding-top: 1rem; +} + +.lora-run-grid > div { + display: flex; + flex-direction: column; +} + +.lora-run-grid strong { + color: var(--lora-accent); + font-family: var(--site-font-display); + font-size: 1.75rem; + font-weight: 400; + line-height: 1; +} + +.lora-run-grid div:last-of-type strong { + color: var(--lora-warm); +} + +.lora-run-grid span { + margin-top: 0.2rem; + color: var(--site-text-muted); + font-size: 0.56rem; +} + +.lora-run-grid > b { + color: var(--site-text-muted); + font-weight: 400; +} + +.lora-technical-note { + margin-top: 1.5rem; + border-top: 1px solid var(--site-border); + border-bottom: 1px solid var(--site-border); +} + +.lora-technical-note summary { + padding: 0.8rem 0; + color: var(--lora-accent); + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; +} + +.lora-technical-note p { + margin: 0; + border-top: 1px solid var(--site-border); + padding: 1rem 0; + color: var(--site-text-secondary); + font-size: 0.84rem; + line-height: 1.65; +} + +.lora-evidence-list { + margin-top: 2rem; + border-top: 1px solid var(--site-border); +} + +.lora-evidence-list article { + display: grid; + grid-template-columns: 8rem 1fr; + gap: 1rem; + border-bottom: 1px solid var(--site-border); + padding: 1rem 0; +} + +.lora-evidence-list h3 { + color: var(--lora-accent); +} + +.lora-evidence-list p { + margin: 0; + color: var(--site-text-secondary); + font-size: 0.84rem; + line-height: 1.62; +} + +.lora-falsification { + border-left-color: var(--lora-warm); +} + +.lora-falsification > strong { + color: var(--lora-warm); +} + +.lora-realism-ladder { + position: relative; + margin: 2rem 0; + padding: 0; + list-style: none; +} + +.lora-realism-ladder::before { + position: absolute; + top: 0.4rem; + bottom: 0.4rem; + left: 0.32rem; + width: 1px; + background: var(--site-border); + content: ""; +} + +.lora-realism-ladder li { + position: relative; + display: grid; + grid-template-columns: 5.8rem 1fr; + gap: 1rem; + padding: 0 0 1.6rem 1.5rem; +} + +.lora-realism-ladder li::before { + position: absolute; + top: 0.35rem; + left: 0; + width: 0.7rem; + aspect-ratio: 1; + border: 2px solid var(--site-bg); + border-radius: 50%; + background: var(--lora-accent); + box-shadow: 0 0 0 1px var(--lora-accent); + content: ""; +} + +.lora-realism-ladder span { + color: var(--lora-accent); + font-size: 0.7rem; + font-weight: 700; +} + +.lora-realism-ladder strong { + display: block; + font-size: 0.95rem; +} + +.lora-realism-ladder p { + grid-column: 2; + margin: -0.7rem 0 0; + color: var(--site-text-secondary); + font-size: 0.82rem; + line-height: 1.58; +} + +.lora-limits { + margin: 1.5rem 0 0; + padding: 0; + border-top: 1px solid var(--site-border); + list-style: none; +} + +.lora-limits li { + border-bottom: 1px solid var(--site-border); + padding: 1rem 0; + color: var(--site-text-secondary); + font-size: 0.88rem; + line-height: 1.62; +} + +.lora-sources { + display: grid; + gap: 0.55rem; + padding: 3rem 0 5rem; +} + +.lora-sources h2 { + margin-bottom: 0.4rem; + font-size: 1rem; +} + +.lora-sources a { + width: fit-content; + color: var(--lora-accent); + font-size: 0.78rem; + line-height: 1.45; + text-decoration: none; +} + +.lora-sources a:hover { + text-decoration: underline; +} + +@media (max-width: 560px) { + .lora-meta span + span::before { + content: none; + } + + .lora-direction-figure { + grid-template-columns: 1fr 2.7rem 1fr; + } + + .lora-rank-gate { + width: 2.7rem; + } + + .lora-definitions > div { + grid-template-columns: 1fr; + gap: 0.35rem; + } + + .topology-controls button { + flex-direction: column; + padding: 0.65rem 0.4rem; + font-size: 0.62rem; + text-align: left; + } + + .topology-world-field { + grid-template-columns: repeat(12, 1fr); + } + + .topology-readout { + grid-template-columns: 1fr; + } + + .topology-readout p + p { + border-top: 1px solid var(--site-border); + border-left: 0; + } + + .lora-prediction-list article { + grid-template-columns: 1.6rem 1fr; + } + + .lora-prediction-list article > strong { + grid-column: 2; + text-align: left; + } + + .lora-comparison > div { + grid-template-columns: 1fr; + gap: 0; + } + + .lora-cipher-figure > div { + grid-template-columns: auto 1fr; + } + + .lora-cipher-figure b { + grid-column: 1 / -1; + padding-left: 2.7rem; + } + + .lora-run-grid strong { + font-size: 1.35rem; + } + + .lora-run-grid span { + max-width: 3.3rem; + font-size: 0.48rem; + line-height: 1.2; + } + + .lora-run-grid > b { + font-size: 0.7rem; + } + + .lora-evidence-list article { + grid-template-columns: 1fr; + gap: 0.4rem; + } + + .lora-realism-ladder li { + grid-template-columns: 1fr; + gap: 0.25rem; + } + + .lora-realism-ladder p { + grid-column: 1; + margin-top: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .topology-world-field i, + .topology-frontier b { + transition: none; + } +} diff --git a/public/lora-topology.js b/public/lora-topology.js new file mode 100644 index 0000000..c35d60a --- /dev/null +++ b/public/lora-topology.js @@ -0,0 +1,62 @@ +(() => { + const instrument = document.querySelector("[data-topology-instrument]"); + if (!instrument) return; + + const modes = { + coherent: { + maps: "1", + sharing: "ACROSS ALL WORLDS", + frontier: "LOWEST", + summary: "Every world uses the same substitution map. Learning in one world helps in every other world, so all examples can reinforce one solution.", + position: "8%", + aria: "Representative sample of worlds that all share one rule", + }, + clustered: { + maps: "32", + sharing: "WITHIN 32 GROUPS", + frontier: "MIDDLE", + summary: "Each map is shared by a group of worlds. Examples reinforce one another inside a cluster, but learning does not transfer across all clusters.", + position: "49%", + aria: "Representative sample of worlds divided into clusters that share rules within each group", + }, + fragmented: { + maps: "1,024", + sharing: "NONE BETWEEN WORLDS", + frontier: "HIGHEST", + summary: "Every world uses its own random map. Each rule is learnable, but solving one world provides no intended help with any other world.", + position: "90%", + aria: "Representative sample of worlds where every world uses a different rule", + }, + }; + + const buttons = Array.from(instrument.querySelectorAll("[data-mode-button]")); + const field = instrument.querySelector(".topology-world-field"); + const mapCount = instrument.querySelector("[data-map-count]"); + const sharing = instrument.querySelector("[data-sharing]"); + const frontier = instrument.querySelector("[data-frontier]"); + const summary = instrument.querySelector("[data-topology-summary]"); + const marker = instrument.querySelector(".topology-frontier b"); + + const render = (mode) => { + const next = modes[mode]; + if (!next) return; + instrument.dataset.mode = mode; + buttons.forEach((button) => { + const active = button.dataset.modeButton === mode; + button.classList.toggle("is-active", active); + button.setAttribute("aria-pressed", String(active)); + }); + mapCount.textContent = next.maps; + sharing.textContent = next.sharing; + frontier.textContent = next.frontier; + summary.textContent = next.summary; + marker.style.setProperty("--frontier-position", next.position); + field.setAttribute("aria-label", next.aria); + }; + + buttons.forEach((button) => { + button.addEventListener("click", () => render(button.dataset.modeButton)); + }); + + render("coherent"); +})(); diff --git a/public/semble.js b/public/semble.js new file mode 100644 index 0000000..b458931 --- /dev/null +++ b/public/semble.js @@ -0,0 +1,65 @@ +(function () { + const filterButtons = Array.from( + document.querySelectorAll("[data-collection-filter]") + ); + const cards = Array.from(document.querySelectorAll(".semble-card")); + const status = document.getElementById("semble-filter-status"); + + if (!filterButtons.length || !cards.length) return; + + function labelFor(filter) { + if (filter === "all") return "all"; + if (filter === "loose") return "loose"; + const button = filterButtons.find( + (item) => + item.dataset.collectionFilter === filter && + item.closest(".semble-filter-bar") + ); + return button?.textContent?.replace(/\d+\s*$/, "").trim() || "selected"; + } + + function applyFilter(filter) { + let visible = 0; + + for (const card of cards) { + const collectionIds = (card.dataset.cardCollections || "") + .split(" ") + .filter(Boolean); + const show = + filter === "all" || + (filter === "loose" && card.dataset.cardLoose === "true") || + collectionIds.includes(filter); + + card.hidden = !show; + if (show) visible += 1; + } + + for (const button of filterButtons) { + button.setAttribute( + "aria-pressed", + button.dataset.collectionFilter === filter ? "true" : "false" + ); + } + + if (status) { + const label = labelFor(filter); + if (filter === "all") { + status.textContent = `Showing all ${visible} cards.`; + } else if (filter === "loose") { + status.textContent = `Showing ${visible} loose card${visible === 1 ? "" : "s"}.`; + } else { + status.textContent = `Showing ${visible} card${visible === 1 ? "" : "s"} in ${label}.`; + } + } + + document + .getElementById("semble-cards-title") + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + for (const button of filterButtons) { + button.addEventListener("click", () => { + applyFilter(button.dataset.collectionFilter || "all"); + }); + } +})(); diff --git a/public/site.css b/public/site.css index 1180b3c..01c6182 100644 --- a/public/site.css +++ b/public/site.css @@ -222,212 +222,1803 @@ h1 { margin: 0.3em 0; } +/* Blog index */ + +.blog-index-shell { + box-sizing: border-box; + width: 100%; + max-width: 68rem; + margin: 0 auto; + padding: 0 clamp(1.25rem, 4vw, 3rem) 5rem; +} + +.blog-index-header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 2rem; + padding: clamp(3rem, 9vw, 7rem) 0 clamp(1.75rem, 4vw, 3rem); +} + +.blog-index-header h1 { + margin: 0; + font-size: clamp(4.25rem, 12vw, 8.75rem); + letter-spacing: 0.025em; + line-height: 0.78; +} + +.blog-index-header dl { + display: flex; + gap: clamp(1.5rem, 4vw, 3rem); + margin: 0; +} + +.blog-index-header dl > div { + display: grid; + gap: 0.2rem; +} + +.blog-index-header dt { + color: var(--site-text-muted); + font-size: 0.6rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.blog-index-header dd { + margin: 0; + color: var(--site-text-secondary); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.blog-lead { + border-top: 1px solid var(--site-text); + border-bottom: 1px solid var(--site-border); +} + +.blog-lead > a { + position: relative; + display: grid; + box-sizing: border-box; + grid-template-columns: minmax(5.5rem, 0.3fr) minmax(0, 1fr); + gap: clamp(1.5rem, 5vw, 4rem); + min-height: clamp(18rem, 34vw, 25rem); + padding: clamp(1.5rem, 4vw, 2.75rem) 0; + overflow: hidden; + color: var(--site-text); + text-decoration: none; +} + +.blog-lead > a::after { + content: ""; + position: absolute; + right: clamp(-9rem, -9vw, -5rem); + bottom: clamp(-13rem, -15vw, -8rem); + width: clamp(19rem, 38vw, 31rem); + aspect-ratio: 1; + border: 1px solid color-mix(in srgb, var(--site-accent) 48%, transparent); + border-radius: 50%; + pointer-events: none; + transition: transform 0.35s ease; +} + +.blog-lead > a:hover::after { + transform: translate(-0.5rem, -0.5rem); +} + +.blog-lead > a:focus-visible, +.blog-recent-post > a:focus-visible, +.blog-archive-year a:focus-visible { + outline: 2px solid var(--site-accent); + outline-offset: -2px; +} + +.blog-lead-marker { + align-self: start; + color: var(--site-accent); + font-family: var(--site-font-body); + font-size: clamp(2.4rem, 6vw, 4.75rem); + font-variant-numeric: tabular-nums; + font-weight: 400; + letter-spacing: -0.08em; + line-height: 0.8; +} + +.blog-lead-copy { + position: relative; + z-index: 1; + display: flex; + align-items: flex-start; + flex-direction: column; + max-width: 47rem; +} + +.blog-lead-meta, +.blog-recent-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; + color: var(--site-text-muted); + font-size: 0.64rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.blog-lead-meta span { + color: var(--site-accent); + font-weight: 700; +} + +.blog-lead time, +.blog-recent-post time, +.blog-archive-year time { + font-variant-numeric: tabular-nums; +} + +.blog-lead h2 { + max-width: 12ch; + margin: clamp(1.5rem, 3vw, 2.5rem) 0 0; + font-size: clamp(2.25rem, 6.2vw, 5.4rem); + letter-spacing: -0.055em; + line-height: 0.94; +} + +.blog-lead-copy > p { + max-width: 38rem; + margin: 1.25rem 0 0; + color: var(--site-text-secondary); + font-size: clamp(0.95rem, 1.5vw, 1.15rem); + line-height: 1.55; +} + +.blog-index-tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 1rem 0 0; + padding: 0; + list-style: none; +} + +.blog-index-tags li { + margin: 0; + padding: 0.25rem 0.5rem; + border: 1px solid var(--site-border); + border-radius: 999px; + color: var(--site-text-muted); + font-size: 0.58rem; + line-height: 1.2; +} + +.blog-read-link { + margin-top: auto; + padding-top: 2rem; + color: var(--site-text-secondary); + font-size: 0.72rem; + font-weight: 600; +} + +.blog-lead > a:hover .blog-read-link, +.blog-recent-post > a:hover h3, +.blog-archive-year a:hover strong { + color: var(--site-accent); +} + +.blog-recent, +.blog-archive { + padding-top: clamp(3rem, 7vw, 5rem); +} + +.blog-index-section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--site-text); +} + +.blog-index-section-heading h2, +.blog-index-section-heading p { + margin: 0; +} + +.blog-index-section-heading h2 { + font-size: 0.86rem; + letter-spacing: -0.01em; +} + +.blog-index-section-heading p { + color: var(--site-text-muted); + font-size: 0.62rem; + text-transform: uppercase; +} + +.blog-recent-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.blog-recent-post { + min-width: 0; + border-bottom: 1px solid var(--site-border); +} + +.blog-recent-post:nth-child(odd) { + padding-right: clamp(1rem, 3vw, 2.5rem); + border-right: 1px solid var(--site-border); +} + +.blog-recent-post:nth-child(even) { + padding-left: clamp(1rem, 3vw, 2.5rem); +} + +.blog-recent-post > a { + display: flex; + box-sizing: border-box; + flex-direction: column; + min-height: 12rem; + padding: 1.25rem 0 1.5rem; + color: var(--site-text); + text-decoration: none; +} + +.blog-recent-meta span { + color: var(--site-accent); + font-family: var(--site-font-body); + font-size: 1rem; + font-variant-numeric: tabular-nums; + letter-spacing: -0.05em; + line-height: 1; +} + +.blog-recent-meta time { + letter-spacing: 0; + text-transform: none; +} + +.blog-recent-post h3 { + margin: 1.2rem 0 0; + font-size: clamp(1.2rem, 2.3vw, 1.65rem); + letter-spacing: -0.035em; + line-height: 1.08; +} + +.blog-recent-post p { + display: -webkit-box; + margin: 0.7rem 0 0; + overflow: hidden; + color: var(--site-text-secondary); + font-size: 0.78rem; + line-height: 1.55; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.blog-recent-post .blog-index-tags { + margin-top: auto; + padding-top: 0.8rem; +} + +.blog-archive-years { + border-bottom: 1px solid var(--site-border); +} + +.blog-archive-year { + display: grid; + grid-template-columns: 6rem minmax(0, 1fr); + border-top: 1px solid var(--site-border); +} + +.blog-archive-year:first-child { + border-top: 0; +} + +.blog-archive-year > h3 { + margin: 0; + padding: 1.35rem 1rem 1.35rem 0; + color: var(--site-accent); + font-family: var(--site-font-display); + font-size: 1.35rem; + font-weight: 400; + letter-spacing: 0.03em; +} + +.blog-archive-year ol { + margin: 0; + padding: 0; + border-left: 1px solid var(--site-border); + list-style: none; +} + +.blog-archive-year li { + margin: 0; + border-bottom: 1px solid var(--site-border); +} + +.blog-archive-year li:last-child { + border-bottom: 0; +} + +.blog-archive-year a { + display: grid; + grid-template-columns: 4.5rem minmax(0, 1fr) auto; + gap: 1rem; + align-items: start; + padding: 0.9rem 0 0.9rem 1.25rem; + color: var(--site-text); + text-decoration: none; +} + +.blog-archive-year time { + padding-top: 0.15rem; + color: var(--site-text-muted); + font-size: 0.65rem; +} + +.blog-archive-copy { + display: grid; + gap: 0.3rem; + min-width: 0; +} + +.blog-archive-copy strong { + font-size: 0.87rem; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.35; +} + +.blog-archive-copy > span { + display: -webkit-box; + overflow: hidden; + color: var(--site-text-muted); + font-size: 0.7rem; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.blog-archive-arrow { + padding: 0.05rem 0.15rem 0 0; + color: var(--site-text-muted); + transition: transform 0.15s; +} + +.blog-archive-year a:hover .blog-archive-arrow { + transform: translateX(0.2rem); +} + +.blog-index-empty { + padding: 4rem 0; + border-top: 1px solid var(--site-text); + color: var(--site-text-muted); +} + +@media (max-width: 680px) { + .blog-index-header { + align-items: flex-start; + flex-direction: column; + gap: 0.85rem; + padding: 2rem 0 1.5rem; + } + + .blog-index-header h1 { + font-size: 3.75rem; + } + + .blog-index-header dl { + gap: 1rem; + } + + .blog-index-header dl > div { + display: flex; + align-items: baseline; + gap: 0.35rem; + } + + .blog-lead > a { + display: block; + min-height: 0; + padding: 1.25rem 0 1.5rem; + } + + .blog-lead > a::after { + right: -6rem; + bottom: -8rem; + width: 16rem; + } + + .blog-lead-marker { + position: absolute; + top: 1.25rem; + left: 0; + width: 3rem; + font-size: 2.25rem; + letter-spacing: -0.08em; + } + + .blog-lead-meta { + box-sizing: border-box; + min-height: 2.25rem; + padding-left: 4rem; + } + + .blog-lead h2 { + margin-top: 1.25rem; + font-size: clamp(2.3rem, 11vw, 3.2rem); + } + + .blog-lead-copy > p { + margin-top: 0.75rem; + } + + .blog-read-link { + margin-top: 1.4rem; + padding-top: 0; + } + + .blog-recent-grid { + grid-template-columns: 1fr; + } + + .blog-recent-post:nth-child(odd), + .blog-recent-post:nth-child(even) { + padding-right: 0; + padding-left: 0; + border-right: 0; + } + + .blog-recent-post > a { + min-height: 0; + padding: 0.9rem 0 1.05rem; + } + + .blog-recent-post h3 { + margin-top: 0.65rem; + font-size: 1.18rem; + } + + .blog-recent-post p { + margin-top: 0.4rem; + -webkit-line-clamp: 2; + } + + .blog-recent-post .blog-index-tags { + margin-top: 0; + padding-top: 0.7rem; + } + + .blog-archive-year { + grid-template-columns: 1fr; + } + + .blog-archive-year > h3 { + padding: 1.35rem 0 0.65rem; + } + + .blog-archive-year ol { + border-left: 0; + } + + .blog-archive-year a { + grid-template-columns: 3.6rem minmax(0, 1fr) auto; + gap: 0.65rem; + padding-left: 0; + } +} + +@media (max-width: 390px) { + .blog-index-header dl { + width: 100%; + justify-content: space-between; + } + + .blog-index-header h1 { + font-size: 3.4rem; + } +} + +/* Compact blog index. It deliberately uses the site's standard page measure. */ + +.blog-index-shell { + max-width: none; + margin: 0; + padding: 0 0 4rem; +} + +.blog-index-header { + align-items: end; + gap: 1rem; + padding: 2.75rem 0 1rem; + border-bottom: 1px solid var(--site-text); +} + +.blog-index-header h1 { + font-size: clamp(2.9rem, 9vw, 4rem); + letter-spacing: 0.01em; + line-height: 0.9; +} + +.blog-index-header .blog-index-summary { + margin: 0 0 0.12rem; + color: var(--site-text-muted); + font-size: 0.7rem; + font-weight: 400; + letter-spacing: 0; + text-transform: none; + white-space: nowrap; +} + +.blog-lead { + border-top: 0; +} + +.blog-lead > a { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + min-height: 0; + padding: 1.4rem 0 1.55rem; + overflow: visible; +} + +.blog-lead > a::after { + display: none; +} + +.blog-lead-copy { + max-width: none; +} + +.blog-lead-meta { + justify-content: flex-start; + min-height: 0; + padding: 0; + font-size: 0.64rem; +} + +.blog-lead-meta time::before { + content: "·"; + padding-right: 1rem; + color: var(--site-text-muted); +} + +.blog-lead h2 { + max-width: none; + margin: 0.7rem 0 0; + font-size: clamp(2rem, 8vw, 3.15rem); + letter-spacing: -0.04em; + line-height: 0.98; +} + +.blog-lead-copy > p { + max-width: 38rem; + margin: 0.55rem 0 0; + font-size: 0.92rem; + line-height: 1.5; +} + +.blog-lead-arrow, +.blog-row-arrow { + align-self: center; + color: var(--site-text-muted); + font-size: 1rem; + transition: color 0.15s, transform 0.15s; +} + +.blog-lead > a:hover .blog-lead-arrow, +.blog-recent-post > a:hover .blog-row-arrow { + color: var(--site-accent); + transform: translateX(0.2rem); +} + +.blog-recent, +.blog-archive { + padding-top: 2.75rem; +} + +.blog-index-section-heading { + padding-bottom: 0.55rem; +} + +.blog-index-section-heading h2 { + font-size: 0.78rem; + font-weight: 600; +} + +.blog-recent-grid { + display: block; +} + +.blog-recent-post:nth-child(odd), +.blog-recent-post:nth-child(even) { + padding-right: 0; + padding-left: 0; + border-right: 0; +} + +.blog-recent-post > a { + display: grid; + grid-template-columns: 6.3rem minmax(0, 1fr) auto; + gap: 0.9rem; + align-items: start; + min-height: 0; + padding: 0.8rem 0 0.85rem; +} + +.blog-recent-date { + padding-top: 0.18rem; + color: var(--site-text-muted); + font-size: 0.65rem; +} + +.blog-recent-post h3 { + margin: 0; + font-size: 1rem; + letter-spacing: -0.02em; + line-height: 1.3; +} + +.blog-recent-post p { + display: block; + margin: 0.22rem 0 0; + overflow: visible; + color: var(--site-text-muted); + font-size: 0.72rem; + line-height: 1.4; + -webkit-line-clamp: unset; +} + +.blog-row-arrow { + padding-top: 0.08rem; +} + +.blog-archive-years { + border-bottom: 0; +} + +.blog-archive-year { + grid-template-columns: 4rem minmax(0, 1fr); +} + +.blog-archive-year > h3 { + padding: 0.78rem 0.7rem 0.78rem 0; + font-family: var(--site-font-body); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0; +} + +.blog-archive-year a { + grid-template-columns: 3.1rem minmax(0, 1fr) auto; + gap: 0.6rem; + padding: 0.55rem 0 0.55rem 0.8rem; +} + +.blog-archive-year time { + padding-top: 0.08rem; + font-size: 0.62rem; +} + +.blog-archive-copy { + gap: 0; +} + +.blog-archive-copy strong { + font-size: 0.8rem; + line-height: 1.3; +} + +.blog-archive-arrow { + font-size: 0.8rem; +} + +@media (max-width: 540px) { + .blog-index-header { + align-items: flex-start; + flex-direction: column; + gap: 0.5rem; + padding: 2rem 0 0.9rem; + } + + .blog-index-header h1 { + font-size: 3rem; + } + + .blog-index-header .blog-index-summary { + width: auto; + } + + .blog-lead > a { + display: grid; + padding: 1.15rem 0 1.3rem; + } + + .blog-lead h2 { + margin-top: 0.6rem; + font-size: 2.25rem; + } + + .blog-recent, + .blog-archive { + padding-top: 2.25rem; + } + + .blog-recent-post > a { + grid-template-columns: 5rem minmax(0, 1fr) auto; + gap: 0.65rem; + padding: 0.72rem 0 0.78rem; + } + + .blog-recent-date { + line-height: 1.35; + } + + .blog-archive-year { + grid-template-columns: 3.2rem minmax(0, 1fr); + } + + .blog-archive-year > h3 { + padding-top: 0.65rem; + } + + .blog-archive-year ol { + border-left: 1px solid var(--site-border); + } + + .blog-archive-year a { + grid-template-columns: 2.8rem minmax(0, 1fr) auto; + padding: 0.48rem 0 0.48rem 0.65rem; + } +} + /* Nav */ -.site-nav { +.site-nav { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 1.5em; + padding: 1em 0; + font-size: 0.875rem; +} + +.site-nav-centered { + justify-content: center; + margin-top: 2em; +} + +.site-nav a { + color: var(--site-text-secondary); + text-decoration: none; + transition: color 0.15s; +} +.site-nav a:hover { + color: var(--site-text); +} + +.site-nav a.active { + color: var(--site-text); + font-weight: 600; +} + +.nav-avatar { + line-height: 0; +} + +.nav-avatar img { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; +} + +.page-title { + margin: 1em 0 0.5em; + font-size: 1.5rem; + font-weight: 700; + line-height: 1.25; +} + +.page-subtitle { + margin: 0 0 1.5em; + color: var(--site-text-secondary); + font-size: 0.9rem; + line-height: 1.5; +} + +.page-subtitle a, +.page-footer a { + color: var(--site-accent); + text-decoration: none; +} + +.page-subtitle a:hover, +.page-footer a:hover { + text-decoration: underline; +} + +.article-page { + margin-top: 1em; +} + +.article-title { + margin: 0 0 0.25em; + font-size: 1.75rem; + font-weight: 700; + line-height: 1.2; +} + +.page-footer { + margin-top: 3em; + padding-top: 1em; + border-top: 1px solid var(--site-border); + color: var(--site-text-muted); + font-size: 0.8125rem; +} + +.protocol-links { + display: grid; + grid-template-columns: minmax(0, 1fr) 1.45rem minmax(0, 1fr); + gap: 0.65rem; + align-items: center; + width: min(13rem, 100%); + margin-inline: auto; + color: var(--site-text-muted); + font-size: 0.72rem; +} + +.protocol-link-leaflet { + justify-self: end; +} + +.protocol-link-atproto { + justify-self: start; +} + +.protocol-mark { + display: grid; + width: 1.45rem; + aspect-ratio: 1; + place-items: center; + border-radius: 50%; +} + +.protocol-mark img, +.protocol-mark > span { + display: block; + width: 100%; + height: 100%; + border-radius: 50%; +} + +.protocol-mark > span { + box-sizing: border-box; + border: 2px solid var(--site-text-secondary); + box-shadow: inset 0 0 0 3px var(--site-bg), inset 0 0 0 4px var(--site-text-secondary); +} + +.page-footer .protocol-mark:hover, +.knowledge-entry .protocol-mark:hover { + text-decoration: none; +} + +.page-footer .protocol-link-leaflet, +.knowledge-entry-footer .protocol-link-leaflet, +.knowledge-protocol-links .protocol-link-leaflet { + color: var(--site-accent); + font: inherit; + font-weight: 500; + letter-spacing: normal; +} + +.page-footer .protocol-link-atproto, +.knowledge-entry-footer .protocol-link-atproto, +.knowledge-protocol-links .protocol-link-atproto { + color: var(--site-accent); + font: inherit; + font-weight: 500; + letter-spacing: normal; +} + +.webring { + display: flex; + justify-content: center; + align-items: baseline; + gap: 0.45em; + margin-top: 0.85em; + color: var(--site-text-muted); + font-size: 0.8125rem; +} + +.webring a { + color: var(--site-text-secondary); + text-decoration: none; +} + +.webring a:hover { + color: var(--site-text); + text-decoration: underline; +} + +/* Post list items */ + +.post-item { + padding: 1em 0; +} +.post-item + .post-item { + border-top: 1px solid var(--site-border); +} + +.post-date { + font-size: 0.8125rem; + color: var(--site-text-muted); +} + +.post-title { + font-family: var(--site-font-body); + font-weight: 600; + color: var(--site-text); + text-decoration: none; + line-height: 1.3; +} +.post-title:hover { + color: var(--site-accent); +} + +.post-summary { + font-size: 0.9rem; + color: var(--site-text-secondary); + margin-top: 0.3em; + line-height: 1.5; +} + +/* Annotations */ + +.annotations-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.annotation-item { + padding: 0.75em 0; +} +.annotation-item + .annotation-item { + border-top: 1px solid var(--site-border); +} + +.annotation-target a { + overflow-wrap: anywhere; +} + +.annotation-body { + font-size: 0.9rem; + margin: 0.3em 0 0; + line-height: 1.5; +} + +.annotation-quote { + font-size: 0.8125rem; + color: var(--site-text-secondary); + border-left: 2px solid var(--site-accent); + padding-left: 0.75em; + margin: 0.4em 0; + font-style: italic; +} + +.annotation-meta { + display: flex; + gap: 0.75em; + align-items: center; + margin-top: 0.3em; + font-size: 0.75rem; + color: var(--site-text-muted); +} + +.library-annotations-section .annotations-list { + border-top: 1px solid var(--site-border); +} + +/* Endorsements */ + +.endorsements-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.75em; +} + +.endorsement-card { + padding: 0.75em 1em; + border: 1px solid var(--site-border); + border-radius: 6px; + transition: border-color 0.15s; +} +.endorsement-card:hover { + border-color: var(--site-accent); +} + +.endorsement-name { + color: var(--site-text); + text-decoration: none; + font-weight: 600; + font-size: 0.9rem; +} +.endorsement-name:hover { + color: var(--site-accent); +} + +.endorsement-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.3em; + font-size: 0.75rem; + color: var(--site-text-muted); +} + +/* Other index */ + +.other-index-header { + margin: clamp(3rem, 10vw, 7rem) 0 clamp(2rem, 6vw, 4rem); +} + +.other-index-header .page-title { + margin: 0.18em 0 0; + font-family: var(--site-font-display); + font-size: clamp(3.5rem, 11vw, 6.75rem); + font-weight: 400; + letter-spacing: 0.025em; + line-height: 0.9; +} + +.other-index-header .page-subtitle { + max-width: 30rem; + margin: 1.25rem 0 0; +} + +.other-index-list { + display: grid; + border-top: 1px solid var(--site-border); +} + +.other-index-entry { + display: grid; + grid-template-columns: 2.2rem minmax(0, 1fr) auto; + gap: clamp(0.8rem, 3vw, 1.5rem); + align-items: start; + padding: 1.4rem 0; + border-bottom: 1px solid var(--site-border); + color: var(--site-text); + text-decoration: none; +} + +.other-index-entry:hover strong { + color: var(--site-accent); +} + +.other-index-number, +.other-index-meta { + padding-top: 0.2rem; + color: var(--site-text-muted); + font-size: 0.62rem; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.other-index-copy { + display: grid; + gap: 0.45rem; +} + +.other-index-copy strong { + font-size: 1.05rem; + transition: color 0.15s; +} + +.other-index-copy > span { + color: var(--site-text-secondary); + font-size: 0.82rem; + line-height: 1.5; +} + +.other-index-meta { + text-align: right; +} + +/* Public knowledge */ + +.knowledge-shell { + padding-bottom: 1rem; +} + +.knowledge-header { + margin: clamp(2rem, 6vw, 3.25rem) 0 1.35rem; +} + +.knowledge-header h1 { + margin: 0 0 0.85rem; + font-size: clamp(2rem, 6vw, 3.25rem); + letter-spacing: 0.02em; +} + +.knowledge-header > p:last-child { + max-width: 34rem; + margin: 0; + color: var(--site-text-secondary); + font-size: 1rem; +} + +.knowledge-notice { + padding: 1rem 1.1rem; + border: 1px solid var(--site-border); + background: color-mix(in srgb, var(--site-accent) 7%, var(--site-bg)); +} + +.knowledge-notice strong { + font-size: 0.82rem; +} + +.knowledge-notice p { + margin: 0.35rem 0 0; + color: var(--site-text-secondary); + font-size: 0.8rem; +} + +.knowledge-notice a, +.knowledge-entry a, +.knowledge-footer a { + color: var(--site-accent); +} + +.knowledge-local-nav { + display: flex; + flex-wrap: wrap; + gap: 0.55rem 1.25rem; + margin: 0 0 1rem; + padding: 0.7rem 0; + border-top: 1px solid var(--site-border); + border-bottom: 1px solid var(--site-border); + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.knowledge-local-nav a { + color: var(--site-text-secondary); + text-decoration: none; +} + +.knowledge-local-nav a:hover { + color: var(--site-accent); +} + +.knowledge-now, +.knowledge-entries { + margin-top: 2.25rem; + scroll-margin-top: 1rem; +} + +.knowledge-now { + padding: 1rem 1.1rem 1.2rem; + border: 1px solid var(--site-border); + background: var(--site-surface); +} + +.knowledge-now > header, +.knowledge-section-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; +} + +.knowledge-now h2, +.knowledge-section-heading h2 { + margin: 0; + font-family: var(--site-font-display); + font-weight: 400; +} + +.knowledge-now h2 { + font-size: 2.25rem; + letter-spacing: 0.04em; +} + +.knowledge-now-description { + margin: 0.2rem 0 0; + color: var(--site-text-secondary); + font-size: 0.76rem; +} + +.knowledge-now-state { + display: grid; + justify-items: end; + gap: 0.35rem; + color: var(--site-text-muted); + font-size: 0.65rem; +} + +.knowledge-now-body { + margin-top: 1rem; + padding-top: 0.2rem; + border-top: 1px solid var(--site-border); + font-size: 0.88rem; +} + +.knowledge-now-body h2 { + margin-top: 1.35rem; + font-family: var(--site-font-body); + font-size: 0.92rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.knowledge-now-body h2:first-child { + margin-top: 1rem; +} + +.knowledge-section-heading { + padding-bottom: 0.7rem; + border-bottom: 1px solid var(--site-border); +} + +.knowledge-section-heading h2 { + font-size: 1.7rem; +} + +.knowledge-section-heading > p { + margin: 0 0 0.2rem; + color: var(--site-text-muted); + font-size: 0.68rem; +} + +.knowledge-controls { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + margin-top: 2.25rem; + padding-bottom: 0.8rem; + border-bottom: 1px solid var(--site-border); +} + +.knowledge-controls label { + display: grid; + flex: 1; + gap: 0.3rem; +} + +.knowledge-controls label > span { + color: var(--site-text-muted); + font-size: 0.64rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.knowledge-controls input { + width: 100%; + padding: 0.5rem 0; + border: 0; + border-bottom: 1px solid var(--site-border); + border-radius: 0; + outline: none; + background: transparent; + color: var(--site-text); + font: inherit; + font-size: 0.85rem; +} + +.knowledge-controls input:focus { + border-bottom-color: var(--site-accent); +} + +.knowledge-controls > p { + margin: 0 0 0.5rem; + color: var(--site-text-muted); + font-size: 0.68rem; + white-space: nowrap; +} + +.knowledge-list { + display: grid; +} + +.knowledge-row { + display: grid; + grid-template-columns: 5.5rem minmax(0, 1fr) auto; + gap: 1rem; + align-items: start; + padding: 1.15rem 0; + border-bottom: 1px solid var(--site-border); +} + +.knowledge-row[hidden] { + display: none; +} + +.knowledge-row-meta, +.knowledge-row-state { + display: grid; + gap: 0.3rem; + padding-top: 0.18rem; + color: var(--site-text-muted); + font-size: 0.62rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.knowledge-row-copy h2, +.knowledge-row-copy h3 { + margin: 0; + font-family: var(--site-font-body); + font-size: 1rem; + line-height: 1.3; +} + +.knowledge-row-copy h2 a, +.knowledge-row-copy h3 a { + color: var(--site-text); + text-decoration: none; +} + +.knowledge-row-copy h2 a:hover, +.knowledge-row-copy h3 a:hover { + color: var(--site-accent); +} + +.knowledge-row-copy > p { + margin: 0.35rem 0 0; + color: var(--site-text-secondary); + font-size: 0.8rem; +} + +.knowledge-row-copy ul { display: flex; - align-items: center; flex-wrap: wrap; - gap: 1.5em; - padding: 1em 0; - font-size: 0.875rem; + gap: 0.35rem; + margin: 0.55rem 0 0; + padding: 0; + list-style: none; } -.site-nav a { +.knowledge-row-copy li, +.knowledge-draft { + padding: 0.12rem 0.38rem; + border: 1px solid var(--site-border); + color: var(--site-text-muted); + font-size: 0.6rem; +} + +.knowledge-row-state { + justify-items: end; + text-align: right; +} + +.knowledge-draft { + border-color: color-mix(in srgb, var(--site-accent) 55%, var(--site-border)); color: var(--site-text-secondary); - text-decoration: none; - transition: color 0.15s; + letter-spacing: 0.04em; } -.site-nav a:hover { - color: var(--site-text); + +.knowledge-empty, +.knowledge-footer, +.knowledge-error { + color: var(--site-text-muted); + font-size: 0.75rem; } -.site-nav a.active { - color: var(--site-text); - font-weight: 600; +.knowledge-empty { + padding: 2rem 0; + border-bottom: 1px solid var(--site-border); } -.nav-avatar { - line-height: 0; +.knowledge-error { + margin-top: 1rem; + padding: 0.8rem 1rem; + border-left: 3px solid #a33; } -.nav-avatar img { - width: 28px; - height: 28px; - border-radius: 50%; - object-fit: cover; +.knowledge-error ul { + margin: 0.35rem 0 0; + padding-left: 1.2rem; } -.page-title { - margin: 1em 0 0.5em; - font-size: 1.5rem; - font-weight: 700; - line-height: 1.25; +.knowledge-footer { + margin-top: 2.25rem; + padding-top: 0.8rem; + border-top: 1px solid var(--site-border); } -.page-subtitle { - margin: 0 0 1.5em; - color: var(--site-text-secondary); - font-size: 0.9rem; - line-height: 1.5; +.knowledge-entry { + margin-top: 2.5rem; } -.page-subtitle a, -.page-footer a { - color: var(--site-accent); +.knowledge-back { + color: var(--site-text-muted) !important; + font-size: 0.72rem; text-decoration: none; } -.page-subtitle a:hover, -.page-footer a:hover { - text-decoration: underline; +.knowledge-entry > header { + margin-top: 2rem; } -.article-page { - margin-top: 1em; +.knowledge-entry h1 { + margin: 0.25rem 0 0.7rem; + font-size: clamp(2rem, 6vw, 3.2rem); } -.article-title { - margin: 0 0 0.25em; - font-size: 1.75rem; - font-weight: 700; - line-height: 1.2; +.knowledge-entry-summary { + max-width: 34rem; + margin: 0; + color: var(--site-text-secondary); + font-size: 0.95rem; } -.page-footer { - margin-top: 3em; - padding-top: 1em; +.knowledge-draft-banner { + margin-top: 1rem; + padding: 0.55rem 0.7rem; + border: 1px solid color-mix(in srgb, var(--site-accent) 55%, var(--site-border)); + color: var(--site-text-secondary); + font-size: 0.72rem; +} + +.knowledge-entry-facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(6.5rem, 1fr)); + margin: 1.6rem 0 2.2rem; border-top: 1px solid var(--site-border); + border-bottom: 1px solid var(--site-border); +} + +.knowledge-entry-facts > div { + padding: 0.65rem 0.5rem 0.65rem 0; +} + +.knowledge-entry-facts dt { color: var(--site-text-muted); - font-size: 0.8125rem; + font-size: 0.58rem; + letter-spacing: 0.1em; + text-transform: uppercase; } -.webring { +.knowledge-entry-facts dd { + margin: 0.18rem 0 0; + font-size: 0.75rem; +} + +.knowledge-entry-body > h1:first-child { + display: none; +} + +.knowledge-entry-body { + min-width: 0; + overflow-wrap: anywhere; +} + +.knowledge-entry-body pre { + max-width: 100%; + box-sizing: border-box; +} + +.knowledge-entry-body table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + font-size: 0.76rem; + white-space: nowrap; +} + +.knowledge-entry-body th, +.knowledge-entry-body td { + padding: 0.42rem 0.58rem; + border: 1px solid var(--site-border); + text-align: left; + vertical-align: top; +} + +.knowledge-entry-section { + margin-top: 2.5rem; + padding-top: 1rem; + border-top: 1px solid var(--site-border); +} + +.knowledge-entry-section > h2 { + margin: 0 0 0.75rem; + font-family: var(--site-font-body); + font-size: 0.9rem; +} + +.knowledge-sources, +.knowledge-connections ul { + margin: 0; + padding-left: 1.2rem; + font-size: 0.78rem; +} + +.knowledge-connections { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 1rem; +} + +.knowledge-connections > h2 { + grid-column: 1 / -1; +} + +.knowledge-connections h3 { + margin: 0 0 0.4rem; + font-size: 0.72rem; +} + +.knowledge-entry-footer { display: flex; - justify-content: center; - align-items: baseline; - gap: 0.45em; - margin-top: 0.85em; + justify-content: space-between; + gap: 1rem; + margin-top: 3rem; + padding-top: 0.8rem; + border-top: 1px solid var(--site-border); color: var(--site-text-muted); - font-size: 0.8125rem; + font-size: 0.68rem; } -.webring a { - color: var(--site-text-secondary); - text-decoration: none; +.knowledge-entry-protocol-footer { + display: block; } -.webring a:hover { - color: var(--site-text); - text-decoration: underline; +.knowledge-entry-footer p { + margin: 0; } -/* Post list items */ +.knowledge-protocol-links { + margin-top: 0.45rem; +} -.post-item { - padding: 1em 0; +@media (max-width: 600px) { + .knowledge-now > header, + .knowledge-section-heading { + align-items: start; + } + + .knowledge-controls { + align-items: stretch; + flex-direction: column; + } + + .knowledge-controls > p { + margin: 0; + } + + .knowledge-row { + grid-template-columns: 4.5rem minmax(0, 1fr); + } + + .knowledge-row-state { + grid-column: 2; + grid-row: 2; + justify-items: start; + text-align: left; + } + + .knowledge-entry-footer { + flex-direction: column; + } } -.post-item + .post-item { + +/* Code activity */ + +.code-page-header { + margin-top: 1rem; +} + +.code-page-header .page-subtitle { + margin-bottom: 1rem; +} + +.code-activity-list { border-top: 1px solid var(--site-border); } -.post-date { - font-size: 0.8125rem; - color: var(--site-text-muted); +.code-activity-section { + padding: 1.35rem 0 1.5rem; + border-bottom: 1px solid var(--site-border); } -.post-title { - font-family: var(--site-font-body); - font-weight: 600; +.code-activity-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1.5rem; + margin-bottom: 1rem; +} + +.code-activity-heading h2, +.code-activity-heading p { + margin: 0; +} + +.code-activity-heading h2 { + font-size: 1rem; + line-height: 1.35; +} + +.code-activity-heading h2 a { color: var(--site-text); text-decoration: none; - line-height: 1.3; } -.post-title:hover { + +.code-activity-heading h2 a:hover { color: var(--site-accent); } -.post-summary { - font-size: 0.9rem; +.code-activity-heading > div > p, +.code-activity-total, +.code-activity-unavailable { + color: var(--site-text-muted); + font-size: 0.7rem; +} + +.code-activity-total { + max-width: 15rem; + text-align: right; +} + +.code-activity-total strong { color: var(--site-text-secondary); - margin-top: 0.3em; - line-height: 1.5; + font-variant-numeric: tabular-nums; } -/* Annotations */ +.code-graph-scroll { + width: 100%; + overflow-x: auto; + padding-bottom: 0.25rem; + scrollbar-width: thin; +} -.annotations-list { - display: flex; - flex-direction: column; - gap: 0; +.code-calendar-graph { + display: grid; + grid-template-rows: repeat(7, 9px); + grid-auto-flow: column; + grid-auto-columns: 9px; + gap: 2px; + width: max-content; } -.annotation-item { - padding: 0.75em 0; +.code-activity-day { + width: 9px; + height: 9px; + border-radius: 2px; + background: var(--site-code-bg); } -.annotation-item + .annotation-item { - border-top: 1px solid var(--site-border); + +.code-activity-day.activity-level-1 { + background: color-mix(in srgb, var(--site-accent) 28%, var(--site-code-bg)); } -.annotation-body { - font-size: 0.9rem; - margin: 0.3em 0 0; - line-height: 1.5; +.code-activity-day.activity-level-2 { + background: color-mix(in srgb, var(--site-accent) 48%, var(--site-code-bg)); } -.annotation-quote { - font-size: 0.8125rem; - color: var(--site-text-secondary); - border-left: 2px solid var(--site-accent); - padding-left: 0.75em; - margin: 0.4em 0; - font-style: italic; +.code-activity-day.activity-level-3 { + background: color-mix(in srgb, var(--site-accent) 72%, var(--site-code-bg)); } -.annotation-meta { +.code-activity-day.activity-level-4 { + background: var(--site-accent); +} + +.code-activity-day-empty { + visibility: hidden; +} + +.code-pinned-repositories { + margin-top: 1.35rem; +} + +.code-pinned-heading { display: flex; - gap: 0.75em; - align-items: center; - margin-top: 0.3em; - font-size: 0.75rem; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.4rem; +} + +.code-pinned-heading h3, +.code-pinned-heading p { + margin: 0; +} + +.code-pinned-heading h3 { + font-size: 0.78rem; +} + +.code-pinned-heading p { color: var(--site-text-muted); + font-size: 0.65rem; } -/* Endorsements */ +.code-pinned-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid var(--site-border); +} -.endorsements-grid { +.code-pinned-repository { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 0.75em; + gap: 0.3rem; + min-width: 0; + padding: 0.8rem 0; + border-bottom: 1px solid var(--site-border); + color: var(--site-text-secondary); + text-decoration: none; } -.endorsement-card { - padding: 0.75em 1em; - border: 1px solid var(--site-border); - border-radius: 6px; - transition: border-color 0.15s; +.code-pinned-repository:nth-child(odd) { + padding-right: 1rem; + border-right: 1px solid var(--site-border); } -.endorsement-card:hover { - border-color: var(--site-accent); + +.code-pinned-repository:nth-child(even) { + padding-left: 1rem; +} + +.code-pinned-repository:hover .code-pinned-repository-name { + color: var(--site-accent); } -.endorsement-name { +.code-pinned-repository-name { + overflow: hidden; color: var(--site-text); - text-decoration: none; + font-size: 0.82rem; font-weight: 600; - font-size: 0.9rem; -} -.endorsement-name:hover { - color: var(--site-accent); + text-overflow: ellipsis; + white-space: nowrap; } -.endorsement-meta { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 0.3em; - font-size: 0.75rem; +.code-pinned-repository > span:last-child:not(.code-pinned-repository-name) { + display: -webkit-box; + overflow: hidden; color: var(--site-text-muted); + font-size: 0.68rem; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +@media (max-width: 520px) { + .code-activity-heading { + gap: 0.75rem; + } + + .code-calendar-graph { + grid-template-rows: repeat(7, 5px); + grid-auto-columns: 5px; + gap: 1px; + } + + .code-activity-day { + width: 5px; + height: 5px; + border-radius: 1px; + } + + .code-pinned-list { + grid-template-columns: 1fr; + } + + .code-pinned-repository:nth-child(odd), + .code-pinned-repository:nth-child(even) { + padding-right: 0; + padding-left: 0; + border-right: 0; + } } /* Margin annotations */ @@ -650,76 +2241,405 @@ h1 { /* Semble */ -.semble-collections { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 0.75em; +.semble-shell { + width: 100%; + padding-bottom: 3rem; } -.semble-collection-card { - display: block; - padding: 0.75em 1em; - border: 1px solid var(--site-border); - border-radius: 6px; - text-decoration: none; - color: inherit; - transition: border-color 0.15s; +.semble-page { + --semble-line: var(--site-border); + --semble-paper: var(--site-surface); } -.semble-collection-card:hover { - border-color: var(--site-accent); + +.semble-hero { + margin: 0; + padding: 0.25rem 0 1.25rem; + border-bottom: 1px solid var(--semble-line); } -.semble-collection-name { +.semble-kicker { + margin: 0; + color: var(--site-text-muted); + font-size: 0.68rem; font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.semble-hero .semble-kicker { + display: none; +} + +.semble-hero h1 { + margin: 0.8em 0 0.5em; + font-size: 1.5rem; + line-height: 1.25; +} + +.semble-intro { + margin: 0; + color: var(--site-text-secondary); font-size: 0.9rem; + line-height: 1.5; +} + +.semble-protocol-line { + margin: 0.55rem 0 0; + color: var(--site-text-muted); + font-size: 0.7rem; +} + +.semble-protocol-line a, +.semble-footer a { + color: inherit; + text-underline-offset: 0.2em; +} + +.semble-totals { + display: flex; + flex-wrap: wrap; + gap: 0.55rem 1.1rem; + margin: 0.9rem 0 0; +} + +.semble-totals div { + display: flex; + gap: 0.3rem; + align-items: baseline; +} + +.semble-totals dt { + color: var(--site-text-muted); + font-size: 0.7rem; +} + +.semble-totals dt a { + color: inherit; + text-decoration-color: transparent; + text-underline-offset: 0.2em; +} + +.semble-totals dt a:hover { color: var(--site-text); + text-decoration-color: currentColor; } -.semble-collection-desc { - font-size: 0.8125rem; +.semble-totals dd { + order: -1; + margin: 0; color: var(--site-text-secondary); - margin-top: 0.2em; - line-height: 1.4; + font-size: 0.7rem; + font-variant-numeric: tabular-nums; } -.semble-collection-meta { - font-size: 0.75rem; +.semble-section { + padding: 1.5rem 0; + border-bottom: 1px solid var(--semble-line); +} + +.semble-section-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.9rem; +} + +.semble-section-heading h2 { + margin: 0; + font-family: var(--site-font-body); + font-size: 1rem; + letter-spacing: -0.015em; + line-height: 1.3; +} + +.semble-section-heading > p { + max-width: 23rem; + margin: 0; color: var(--site-text-muted); - margin-top: 0.4em; + font-size: 0.7rem; + text-align: right; +} + +.semble-section-heading > p a { + color: inherit; + text-decoration-color: color-mix(in srgb, currentColor 52%, transparent); + text-underline-offset: 0.2em; } -.semble-cards { +.semble-section-heading > p a:hover { + color: var(--site-text); + text-decoration-color: currentColor; +} + +.semble-card-source, +.semble-card-footer { display: flex; - flex-direction: column; - gap: 0; + align-items: center; + justify-content: space-between; + gap: 1rem; } -.semble-card-item { - padding: 0.75em 0; +.semble-filter-bar { + display: flex; + gap: 0.45rem; + margin-bottom: 0.9rem; + padding-bottom: 0.25rem; + overflow-x: auto; + scrollbar-width: thin; } -.semble-card-item + .semble-card-item { - border-top: 1px solid var(--site-border); + +.semble-filter-bar button, +.semble-card-membership button { + flex: 0 0 auto; + border: 1px solid var(--site-border); + color: var(--site-text-secondary); + background: var(--site-surface); + cursor: pointer; + font: inherit; +} + +.semble-filter-bar button { + padding: 0.55rem 0.8rem; + border-radius: 999px; + font-size: 0.72rem; +} + +.semble-filter-bar button span { + margin-left: 0.3rem; + color: var(--site-text-muted); + font-variant-numeric: tabular-nums; } -.semble-card-item a:hover { +.semble-filter-bar button:hover, +.semble-filter-bar button[aria-pressed="true"] { + border-color: var(--site-text); + color: var(--site-bg); + background: var(--site-text); +} + +.semble-filter-bar button[aria-pressed="true"] span { + color: inherit; + opacity: 0.7; +} + +.semble-card-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.75rem; + align-items: start; +} + +.semble-card { + overflow: hidden; + border: 1px solid var(--site-border); + border-radius: 0.65rem; + background: var(--semble-paper); + transition: border-color 0.15s; +} + +.semble-card[hidden] { + display: none; +} + +.semble-card:hover { + border-color: var(--site-text-muted); +} + +.semble-card-body { + padding: 0.9rem 1rem; +} + +.semble-card-source { + color: var(--site-text-muted); + font-size: 0.61rem; + letter-spacing: 0.055em; + text-transform: uppercase; +} + +.semble-card-source span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.semble-card-source span:last-child { + flex: 0 0 auto; +} + +.semble-card h3 { + margin: 0.45rem 0 0; + font-size: 0.95rem; + letter-spacing: -0.02em; + line-height: 1.28; +} + +.semble-card h3 a { + color: var(--site-text); + text-decoration: none; +} + +.semble-card h3 a:hover { color: var(--site-accent); } -.semble-card-note { - font-size: 0.8125rem; +.semble-external { + display: inline-block; + margin-left: 0.25em; + color: var(--site-text-muted); + font-size: 0.78em; + font-weight: 400; + transition: transform 0.15s; +} + +.semble-card h3 a:hover .semble-external { + transform: translate(1px, -1px); +} + +.semble-card-description { + display: -webkit-box; + margin: 0.45rem 0 0; + overflow: hidden; color: var(--site-text-secondary); - border-left: 2px solid var(--site-accent); - padding-left: 0.75em; - margin: 0.3em 0; + font-size: 0.78rem; + line-height: 1.55; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.semble-card-note { + margin: 0.7rem 0 0; + padding: 0.65rem 0.75rem; + border-left: 3px solid var(--site-accent); + color: var(--site-text); + background: color-mix(in srgb, var(--site-accent) 7%, var(--site-surface)); +} + +.semble-card-note span { + display: block; + margin-bottom: 0.25rem; + color: var(--site-accent); + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.semble-card-note p { + margin: 0; + font-size: 0.8rem; font-style: italic; + line-height: 1.45; } -.semble-card-meta { +.semble-card-footer { + margin-top: 0.7rem; + padding-top: 0.65rem; + border-top: 1px solid var(--site-border); +} + +.semble-card-membership { display: flex; - gap: 0.75em; - font-size: 0.75rem; + flex-wrap: wrap; + gap: 0.3rem; +} + +.semble-card-membership button, +.semble-card-membership > span { + padding: 0.25rem 0.5rem; + border-radius: 999px; color: var(--site-text-muted); - margin-top: 0.3em; + font-size: 0.6rem; +} + +.semble-card-membership button:hover, +.semble-card-membership button[aria-pressed="true"] { + border-color: var(--site-accent); + color: var(--site-accent); +} + +.semble-record-link { + color: var(--site-text-muted); + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; + font-size: 0.58rem; + text-decoration: none; +} + +.semble-record-link:hover { + color: var(--site-text); + text-decoration: underline; +} + +.semble-unavailable { + margin: clamp(3rem, 10vw, 8rem) auto; + padding: 2rem; + border: 1px solid var(--site-border); + border-radius: 1rem; + text-align: center; +} + +.semble-unavailable h2 { + margin: 0.5rem 0 0; + font-size: 1.5rem; +} + +.semble-unavailable p:last-child, +.semble-empty { + color: var(--site-text-muted); + font-size: 0.82rem; +} + +.semble-footer { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 2rem; + padding: 1.4rem 0 0; + color: var(--site-text-muted); + font-size: 0.7rem; +} + +.semble-footer p { + margin: 0; +} + +.semble-footer div { + display: flex; + gap: 1rem; + flex: 0 0 auto; +} + +@media (max-width: 760px) { + .semble-section-heading { + align-items: baseline; + } + + .semble-section-heading > p { + text-align: right; + } +} + +@media (max-width: 520px) { + .semble-card-body { + padding: 0.75rem; + } + + .semble-footer { + align-items: flex-start; + flex-direction: column; + gap: 0.75rem; + } + +} + +@media (max-width: 520px) { + .other-index-entry { + grid-template-columns: 1.8rem minmax(0, 1fr); + } + + .other-index-meta { + display: none; + } } /* Responsive: stack columns on mobile */ diff --git a/public/thoughtstore-guide.css b/public/thoughtstore-guide.css new file mode 100644 index 0000000..ca7ba8b --- /dev/null +++ b/public/thoughtstore-guide.css @@ -0,0 +1,254 @@ +.store-guide-shell { + --store-purple: #70567d; + --store-green: #3f735f; + --store-orange: #a34f3e; + --store-pale: color-mix(in srgb, var(--store-purple) 8%, var(--site-bg)); +} + +:root[data-theme="dark"] .store-guide-shell { + --store-purple: #c2a7ce; + --store-green: #8fc2a9; + --store-orange: #ec9585; + --store-pale: color-mix(in srgb, var(--store-purple) 10%, var(--site-bg)); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .store-guide-shell { + --store-purple: #c2a7ce; + --store-green: #8fc2a9; + --store-orange: #ec9585; + --store-pale: color-mix(in srgb, var(--store-purple) 10%, var(--site-bg)); + } +} + +.store-guide-shell *, +.store-guide-shell *::before, +.store-guide-shell *::after { box-sizing: border-box; } + +.store-guide { margin-top: 1rem; } + +.store-header { + padding: clamp(2.8rem, 9vw, 5.2rem) 0 clamp(2.5rem, 7vw, 4rem); + border-bottom: 1px solid var(--site-border); +} + +.store-backlink { + color: var(--site-text-muted); + font-size: .72rem; + font-weight: 600; + letter-spacing: .08em; + text-decoration: none; + text-transform: uppercase; +} + +.store-backlink:hover { color: var(--store-purple); } + +.store-header h1 { + max-width: 38rem; + margin: 1.25rem 0 0; + font-size: clamp(3rem, 10vw, 5rem); + letter-spacing: .02em; + line-height: .94; +} + +.store-deck { + max-width: 37rem; + margin: 1.6rem 0 0; + color: var(--site-text-secondary); + font-size: clamp(1.05rem, 2.5vw, 1.25rem); + line-height: 1.6; +} + +.store-meta { + display: flex; + flex-wrap: wrap; + gap: .55rem 1.25rem; + margin-top: 2rem; + color: var(--site-text-muted); + font-size: .7rem; +} + +.store-meta span + span::before { content: "·"; margin-right: 1.25rem; } + +.store-prose { font-size: 1rem; line-height: 1.75; } + +.store-prose section { + padding: clamp(3rem, 8vw, 4.8rem) 0; + border-bottom: 1px solid var(--site-border); +} + +.store-prose h2, +.store-sources h2 { + margin: 0 0 1.2rem; + font-family: var(--site-font-body); + font-size: clamp(1.45rem, 4vw, 1.85rem); + font-weight: 700; + letter-spacing: -.025em; + line-height: 1.2; +} + +.store-prose p { margin: 1rem 0; } +.store-prose code, +.store-sources code { + border-radius: 3px; + padding: .12em .34em; + background: var(--site-code-bg); + font-family: "SFMono-Regular", Consolas, monospace; + font-size: .86em; +} + +.store-lede { + margin-top: 0 !important; + font-size: clamp(1.22rem, 3.5vw, 1.48rem); + letter-spacing: -.02em; + line-height: 1.5; +} + +.store-stack-figure, +.store-race-figure, +.store-history-figure { + margin: 2.25rem 0 0; + border-top: 1px solid var(--site-border); +} + +.store-stack-layer { + display: grid; + grid-template-columns: 7rem 1fr; + gap: .25rem 1.25rem; + border-bottom: 1px solid var(--site-border); + padding: 1.15rem 0; +} + +.store-stack-layer > strong { font-size: 1rem; } +.store-stack-layer > p { grid-column: 2; margin: 0; color: var(--site-text-muted); font-size: .82rem; line-height: 1.5; } +.store-stack-callers > div { display: flex; flex-wrap: wrap; gap: .4rem; } +.store-stack-callers > div span { border: 1px solid var(--site-border); border-radius: 999px; padding: .12rem .55rem; font-size: .75rem; } +.store-layer-label { color: var(--site-text-muted); font-size: .65rem; font-weight: 700; letter-spacing: .1em; } + +.store-contract-line { + display: grid; + grid-template-columns: 7rem 1fr; + gap: .2rem 1.25rem; + margin: 0; + border-bottom: 1px solid var(--store-purple); + padding: 1rem 0; + color: var(--store-purple); +} + +.store-contract-line span { font-weight: 700; } +.store-contract-line small { grid-column: 2; font-size: .72rem; } +.store-stack-adapter { background: linear-gradient(90deg, var(--store-pale), transparent 70%); } + +.store-stack-figure figcaption, +.store-race-figure figcaption, +.store-history-figure figcaption { + padding: .85rem 0; + color: var(--site-text-muted); + font-size: .74rem; + line-height: 1.55; +} + +.store-equation { + display: grid; + grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr; + align-items: stretch; + margin: 2rem 0; + border-top: 1px solid var(--site-border); + border-bottom: 1px solid var(--site-border); +} + +.store-equation span { display: grid; place-items: center; min-height: 6rem; padding: .8rem; text-align: center; font-size: .8rem; font-weight: 600; line-height: 1.35; } +.store-equation span:first-child { color: var(--store-purple); } +.store-equation b { display: grid; place-items: center; color: var(--site-text-muted); font-size: 1.1rem; font-weight: 400; } + +.store-race-figure { + display: grid; + grid-template-columns: 1fr 2rem 1fr; + gap: 0 1rem; +} + +.race-column { display: grid; align-content: start; gap: .65rem; padding: 1.4rem 0; } +.race-column > span { border-left: 2px solid var(--site-border); padding-left: .7rem; color: var(--site-text-muted); font-size: .8rem; } +.race-column .race-insert { border-color: var(--store-orange); color: var(--store-orange); } +.race-time { display: flex; flex-direction: column; align-items: center; padding: 1.4rem 0; color: var(--site-text-muted); font-size: .62rem; } +.race-time i { width: 1px; min-height: 4.5rem; flex: 1; background: var(--site-border); } +.race-result, +.race-required { grid-column: 1 / -1; display: grid; grid-template-columns: 8.5rem 1fr; gap: 1rem; border-top: 1px solid var(--site-border); padding: .9rem 0; } +.race-result span, +.race-required span { color: var(--site-text-muted); font-size: .64rem; font-weight: 700; letter-spacing: .08em; } +.race-result strong { color: var(--store-orange); } +.race-required strong { color: var(--store-purple); } +.store-race-figure figcaption { grid-column: 1 / -1; border-top: 1px solid var(--site-border); } + +.store-checks { margin: 2.1rem 0 0; border-top: 1px solid var(--site-text); } +.store-check-head, +.store-check-row { display: grid; grid-template-columns: 1.05fr 1.5fr .9fr .85fr; gap: 1rem; border-bottom: 1px solid var(--site-border); padding: .85rem 0; } +.store-check-head { color: var(--site-text-muted); font-size: .62rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.store-check-row { align-items: start; font-size: .76rem; line-height: 1.45; } +.store-check-row > span:not(.store-state) { color: var(--site-text-secondary); } +.store-state { font-weight: 700; } +.store-state-proven { color: var(--store-green); } +.store-state-gap { color: var(--store-orange); } + +.store-note { margin-top: 2rem; border-left: 3px solid var(--store-purple); padding: .1rem 0 .1rem 1.1rem; } +.store-note > strong { color: var(--store-purple); font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; } +.store-note p { margin: .55rem 0 0; } + +.store-history-figure { display: grid; grid-template-columns: 1fr 1fr; gap: 0 2rem; } +.history-current, +.history-contract { padding: 1.25rem 0 .5rem; } +.history-label { display: block; margin-bottom: .9rem; color: var(--site-text-muted); font-size: .64rem; font-weight: 700; letter-spacing: .09em; } +.history-row { display: grid; grid-template-columns: 3rem 1fr; gap: .1rem .65rem; border-left: 3px solid var(--store-purple); padding: .55rem 0 .55rem .75rem; } +.history-row small { grid-row: 1 / 3; color: var(--site-text-muted); } +.history-row span { color: var(--site-text-muted); font-size: .75rem; } +.history-row-old { opacity: .42; } +.history-overwrite { padding: .35rem 0 .35rem 3.7rem; color: var(--store-orange); font-size: .68rem; } +.history-current > p, +.history-contract > p { color: var(--site-text-muted); font-size: .76rem; line-height: 1.5; } +.store-history-figure figcaption { grid-column: 1 / -1; border-top: 1px solid var(--site-border); } + +.store-change-list { margin: 2rem 0 0; padding: 0; list-style: none; border-top: 1px solid var(--site-border); } +.store-change-list li { display: grid; grid-template-columns: 2.5rem 1fr; gap: 1rem; border-bottom: 1px solid var(--site-border); padding: 1.15rem 0; } +.store-change-list li > span { color: var(--store-purple); font-size: .7rem; font-weight: 700; } +.store-change-list strong { display: block; line-height: 1.4; } +.store-change-list p { margin: .35rem 0 0; color: var(--site-text-secondary); font-size: .88rem; line-height: 1.6; } + +.store-not-list { margin: 2rem 0; border-top: 1px solid var(--site-border); } +.store-not-list > div { display: grid; grid-template-columns: 11rem 1fr; gap: 1.2rem; border-bottom: 1px solid var(--site-border); padding: .95rem 0; } +.store-not-list dt { color: var(--store-purple); font-size: .82rem; font-weight: 700; } +.store-not-list dd { margin: 0; color: var(--site-text-secondary); font-size: .88rem; } +.store-conclusion { margin: 2.3rem 0 0 !important; font-size: clamp(1.2rem, 3vw, 1.4rem); line-height: 1.55; } + +.store-sources { padding: 3.5rem 0 5rem; color: var(--site-text-muted); font-size: .78rem; line-height: 1.65; } +.store-sources h2 { color: var(--site-text); font-size: 1rem; } +.store-sources a { color: var(--store-purple); } + +@media (max-width: 640px) { + .store-meta { display: grid; gap: .25rem; } + .store-meta span + span::before { content: none; } + .store-stack-layer, + .store-contract-line { grid-template-columns: 1fr; gap: .35rem; } + .store-stack-layer > p, + .store-contract-line small { grid-column: 1; } + .store-equation { grid-template-columns: 1fr; } + .store-equation span { min-height: auto; padding: .8rem .4rem; } + .store-equation b { min-height: 1.6rem; } + .store-check-head { display: none; } + .store-check-row { grid-template-columns: 1fr; gap: .3rem; padding: 1rem 0; } + .store-check-row > span:nth-child(2)::before { content: "Test: "; font-weight: 700; } + .store-check-row > span:nth-child(3)::before { content: "Requires: "; font-weight: 700; } + .store-history-figure { grid-template-columns: 1fr; } + .history-current { border-bottom: 1px solid var(--site-border); } + .store-not-list > div { grid-template-columns: 1fr; gap: .3rem; } +} + +@media (max-width: 480px) { + .store-race-figure { grid-template-columns: 1fr; } + .race-time { display: none; } + .race-result, + .race-required { grid-column: 1; grid-template-columns: 1fr; gap: .25rem; } +} + +@media (prefers-reduced-motion: reduce) { + .store-guide-shell * { scroll-behavior: auto !important; } +} diff --git a/scripts/check-knowledge.ts b/scripts/check-knowledge.ts new file mode 100644 index 0000000..fea8748 --- /dev/null +++ b/scripts/check-knowledge.ts @@ -0,0 +1,57 @@ +import { resolve } from "node:path"; +import { knowledgeContentDigest, loadKnowledgeGraph } from "../src/knowledge.ts"; +import { loadKnowledgePolicy, scanKnowledgeDraft } from "./knowledge-policy.ts"; + +async function main(): Promise { + const includeDrafts = process.argv.includes("--include-drafts"); + const graph = await loadKnowledgeGraph({ + root: resolve(process.cwd(), "knowledge"), + includeDrafts, + }); + const policy = loadKnowledgePolicy(); + const findings = graph.entries.flatMap((entry) => { + const entryFindings = scanKnowledgeDraft(entry.body, policy).map((finding) => ({ + slug: entry.slug, + ...finding, + })); + + if (entry.kind === "person" && !policy.allowedPeopleSlugs.includes(entry.slug)) { + entryFindings.push({ + slug: entry.slug, + severity: "block", + code: "unapproved-person-entry", + detail: "person slug is not explicitly allowlisted", + }); + } + if (entry.kind === "person" && entry.claimMode !== "factual") { + entryFindings.push({ + slug: entry.slug, + severity: "block", + code: "person-entry-mode", + detail: "person entries must remain factual", + }); + } + + return entryFindings; + }); + const blocking = findings.filter((finding) => finding.severity === "block"); + const contentDigests = Object.fromEntries( + graph.entries.map((entry) => [entry.slug, knowledgeContentDigest(entry)]), + ); + + console.log(JSON.stringify({ + entries: graph.entries.length, + drafts: graph.entries.filter((entry) => entry.draft).length, + published: graph.entries.filter((entry) => !entry.draft).length, + graphErrors: graph.errors, + findings, + contentDigests, + }, null, 2)); + + if (graph.errors.length > 0 || blocking.length > 0) process.exit(1); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/knowledge-policy.ts b/scripts/knowledge-policy.ts new file mode 100644 index 0000000..f329bb4 --- /dev/null +++ b/scripts/knowledge-policy.ts @@ -0,0 +1,148 @@ +import { readFileSync } from "node:fs"; +import { relative, resolve, sep } from "node:path"; + +export type KnowledgeKind = + | "concept" + | "journal" + | "lesson" + | "organization" + | "person" + | "place" + | "practice" + | "project"; + +interface SourceMapping { + pattern: string; + decision: "stage" | "manual" | "deny"; + kind: KnowledgeKind; + topics: string[]; +} + +export interface KnowledgePolicy { + version: number; + defaultDecision: "deny"; + vaultRoot: string; + sourceMappings: SourceMapping[]; + blockedPrefixes: string[]; + blockedWikilinkPrefixes: string[]; + allowedRelationshipPredicates: string[]; + allowedPeopleSlugs: string[]; + peopleDefault: "deny"; +} + +export interface SourceDecision { + relativePath: string; + decision: SourceMapping["decision"] | "deny"; + mapping?: SourceMapping; + reason: string; +} + +export function loadKnowledgePolicy( + path = resolve(process.cwd(), "knowledge/policy.json"), +): KnowledgePolicy { + const policy = JSON.parse(readFileSync(path, "utf8")) as KnowledgePolicy; + policy.vaultRoot = policy.vaultRoot.replace( + /^\$HOME(?=\/|$)/, + process.env.HOME ?? "", + ); + return policy; +} + +function normalizePath(path: string): string { + return path.split(sep).join("/").replace(/^\.\//, ""); +} + +function matchesPattern(path: string, pattern: string): boolean { + const escaped = pattern + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + .replace(/\*\*/g, "\0") + .replace(/\*/g, "[^/]*") + .replace(/\0/g, ".*"); + return new RegExp(`^${escaped}$`).test(path); +} + +export function decideKnowledgeSource( + sourcePath: string, + policy: KnowledgePolicy, +): SourceDecision { + const vaultRoot = resolve(policy.vaultRoot); + const absolute = resolve(sourcePath); + const relativePath = normalizePath(relative(vaultRoot, absolute)); + + if (relativePath.startsWith("../") || relativePath === "..") { + return { relativePath, decision: "deny", reason: "source is outside the configured vault" }; + } + + const blocked = policy.blockedPrefixes.find((prefix) => relativePath.startsWith(prefix)); + if (blocked) { + return { relativePath, decision: "deny", reason: `source is under blocked prefix ${blocked}` }; + } + + const mapping = policy.sourceMappings.find((item) => matchesPattern(relativePath, item.pattern)); + if (!mapping) { + return { relativePath, decision: policy.defaultDecision, reason: "no allowlisted source mapping" }; + } + + return { + relativePath, + decision: mapping.decision, + mapping, + reason: `matched ${mapping.pattern}`, + }; +} + +export interface PrivacyFinding { + severity: "block" | "review"; + code: string; + detail: string; +} + +export function scanKnowledgeDraft( + body: string, + policy: KnowledgePolicy, +): PrivacyFinding[] { + const findings: PrivacyFinding[] = []; + const wikilinks = [...body.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g)]; + + for (const match of wikilinks) { + const target = match[1].trim(); + const blocked = policy.blockedWikilinkPrefixes.find((prefix) => target.startsWith(prefix)); + findings.push({ + severity: blocked ? "block" : "review", + code: blocked ? "private-wikilink" : "unmapped-wikilink", + detail: target, + }); + } + + const checks: Array<[RegExp, PrivacyFinding["severity"], string, string]> = [ + [/\/(?:home|Users)\/[A-Za-z0-9._/-]+/g, "block", "local-path", "local filesystem path"], + [/\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b/g, "review", "email-address", "email address"], + [/(?:telegram|signal|discord):\s*\d+/gi, "block", "private-route", "private channel identifier"], + [/\b(?:api[_ -]?key|app password|secret|credential)\b/gi, "review", "credential-language", "credential-related language"], + [/<(?:script|iframe|object|embed)\b/gi, "block", "active-html", "active HTML"], + ]; + + for (const [pattern, severity, code, label] of checks) { + if (pattern.test(body)) findings.push({ severity, code, detail: label }); + } + + return findings; +} + +export function publicizeObsidianMarkdown(body: string, title: string): string { + let transformed = body + .replace(/^\[\[(?:agents\/co-3|co-3)(?:\|[^\]]+)?\]\]\s+said:\s*\n+/im, "") + .replace(/\[\[([^\]|#]+)\|([^\]]+)\]\]/g, "$2") + .replace(/\[\[([^\]#]+)(?:#[^\]]+)?\]\]/g, (_match, target: string) => { + const label = target.split("/").pop() ?? target; + return label.replace(/[-_]/g, " "); + }) + .trim(); + + const firstHeading = transformed.match(/^#\s+(.+)\n+/); + if (firstHeading?.[1].trim().toLowerCase() === title.trim().toLowerCase()) { + transformed = transformed.slice(firstHeading[0].length).trim(); + } + + return transformed; +} diff --git a/scripts/leaflet-knowledge.ts b/scripts/leaflet-knowledge.ts new file mode 100644 index 0000000..3947ad3 --- /dev/null +++ b/scripts/leaflet-knowledge.ts @@ -0,0 +1,307 @@ +import { createHash } from "node:crypto"; +import { marked } from "marked"; +import type { KnowledgeEntry } from "../src/knowledge.ts"; + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function digestStandardDocument(record: SiteStandardDocument): string { + return `sha256:${createHash("sha256").update(canonicalJson(record)).digest("hex")}`; +} + +interface ByteSlice { + byteStart: number; + byteEnd: number; +} + +type FacetFeature = + | { $type: "pub.leaflet.richtext.facet#link"; uri: string } + | { $type: "pub.leaflet.richtext.facet#bold" } + | { $type: "pub.leaflet.richtext.facet#italic" } + | { $type: "pub.leaflet.richtext.facet#code" }; + +interface Facet { + index: ByteSlice; + features: FacetFeature[]; +} + +interface LeafletTextualBlock { + $type: + | "pub.leaflet.blocks.text" + | "pub.leaflet.blocks.header" + | "pub.leaflet.blocks.blockquote"; + plaintext: string; + facets?: Facet[]; + level?: number; +} + +interface LeafletListItem { + $type: + | "pub.leaflet.blocks.orderedList#listItem" + | "pub.leaflet.blocks.unorderedList#listItem"; + content: LeafletTextualBlock; + children: LeafletListItem[]; +} + +interface LeafletListBlock { + $type: + | "pub.leaflet.blocks.orderedList" + | "pub.leaflet.blocks.unorderedList"; + children: LeafletListItem[]; + startIndex?: number; +} + +interface LeafletHorizontalRule { + $type: "pub.leaflet.blocks.horizontalRule"; +} + +type LeafletBlock = LeafletTextualBlock | LeafletListBlock | LeafletHorizontalRule; + +export interface LeafletPageBlock { + $type: "pub.leaflet.pages.linearDocument#block"; + block: LeafletBlock; +} + +export interface SiteStandardDocument { + $type: "site.standard.document"; + site: string; + title: string; + description: string; + publishedAt: string; + updatedAt: string; + path: string; + tags: string[]; + textContent: string; + content: { + $type: "pub.leaflet.content"; + pages: Array<{ + $type: "pub.leaflet.pages.linearDocument"; + id: string; + blocks: LeafletPageBlock[]; + }>; + }; +} + +function parseInline(input: string): { plaintext: string; facets: Facet[] } { + const encoder = new TextEncoder(); + const normalized = input + .replace(/<((?:https?|mailto):[^>]+)>/g, "$1") + .replace(/\\([\\`*_{}[\]()#+\-.!])/g, "$1"); + const tokenRe = new RegExp( + [ + "\\*\\*\\[([^\\]]+)\\]\\(([^)]+)\\)\\*\\*", + "\\*\\*([^*]+?)\\*\\*|__([^_]+?)__", + "\\[([^\\]]+)\\]\\(([^)]+)\\)", + "(? { + if (!plainBuffer) return; + plaintext += plainBuffer; + byteOffset += encoder.encode(plainBuffer).length; + plainBuffer = ""; + }; + const emit = (text: string, features: FacetFeature[]) => { + flush(); + const byteStart = byteOffset; + plaintext += text; + byteOffset += encoder.encode(text).length; + facets.push({ index: { byteStart, byteEnd: byteOffset }, features }); + }; + + let match: RegExpExecArray | null; + while ((match = tokenRe.exec(normalized)) !== null) { + if (match[1] !== undefined) { + emit(match[1], [ + { $type: "pub.leaflet.richtext.facet#bold" }, + { $type: "pub.leaflet.richtext.facet#link", uri: match[2] }, + ]); + } else if (match[3] !== undefined || match[4] !== undefined) { + emit(match[3] ?? match[4], [{ $type: "pub.leaflet.richtext.facet#bold" }]); + } else if (match[5] !== undefined) { + emit(match[5], [{ $type: "pub.leaflet.richtext.facet#link", uri: match[6] }]); + } else if (match[7] !== undefined || match[8] !== undefined) { + emit(match[7] ?? match[8], [{ $type: "pub.leaflet.richtext.facet#italic" }]); + } else if (match[9] !== undefined) { + emit(match[9], [{ $type: "pub.leaflet.richtext.facet#code" }]); + } else { + plainBuffer += match[0]; + } + } + flush(); + return { plaintext, facets }; +} + +function textualBlock( + type: LeafletTextualBlock["$type"], + markdown: string, + options?: { level?: number; prefix?: string; feature?: FacetFeature }, +): LeafletPageBlock { + const rich = parseInline(`${options?.prefix ?? ""}${markdown}`.replace(/\s*\n\s*/g, " ").trim()); + if (options?.feature && rich.plaintext) { + rich.facets.unshift({ + index: { byteStart: 0, byteEnd: new TextEncoder().encode(rich.plaintext).length }, + features: [options.feature], + }); + } + const block: LeafletTextualBlock = { $type: type, plaintext: rich.plaintext }; + if (rich.facets.length > 0) block.facets = rich.facets; + if (options?.level) block.level = options.level; + return { $type: "pub.leaflet.pages.linearDocument#block", block }; +} + +export function markdownToLeafletBlocks(markdown: string): LeafletPageBlock[] { + const tokens = marked.lexer(markdown); + const blocks: LeafletPageBlock[] = []; + + for (const token of tokens as any[]) { + switch (token.type) { + case "space": + case "def": + break; + case "heading": + blocks.push(textualBlock("pub.leaflet.blocks.header", token.text, { level: token.depth })); + break; + case "paragraph": + case "text": + blocks.push(textualBlock("pub.leaflet.blocks.text", token.text)); + break; + case "blockquote": { + const text = token.text || token.raw.replace(/^>\s?/gm, ""); + blocks.push(textualBlock("pub.leaflet.blocks.blockquote", text)); + break; + } + case "code": { + const label = token.lang ? `${token.lang}\n` : ""; + blocks.push(textualBlock("pub.leaflet.blocks.text", `${label}${token.text}`, { + feature: { $type: "pub.leaflet.richtext.facet#code" }, + })); + break; + } + case "list": + { + const listName = token.ordered ? "orderedList" : "unorderedList"; + const children = token.items.map((item: any): LeafletListItem => { + const rich = parseInline(item.text.replace(/\s*\n\s*/g, " ")); + return { + $type: `pub.leaflet.blocks.${listName}#listItem`, + content: { + $type: "pub.leaflet.blocks.text", + plaintext: rich.plaintext, + ...(rich.facets.length > 0 ? { facets: rich.facets } : {}), + }, + children: [], + } as LeafletListItem; + }); + blocks.push({ + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: `pub.leaflet.blocks.${listName}`, + children, + ...(token.ordered ? { startIndex: Number(token.start ?? 1) } : {}), + } as LeafletListBlock, + }); + } + break; + case "table": { + const header = token.header.map((cell: any) => cell.text).join(" | "); + blocks.push(textualBlock("pub.leaflet.blocks.text", header, { + feature: { $type: "pub.leaflet.richtext.facet#bold" }, + })); + token.rows.forEach((row: any[]) => { + blocks.push(textualBlock("pub.leaflet.blocks.text", row.map((cell) => cell.text).join(" | "))); + }); + break; + } + case "hr": + blocks.push({ + $type: "pub.leaflet.pages.linearDocument#block", + block: { $type: "pub.leaflet.blocks.horizontalRule" }, + }); + break; + case "html": + throw new Error("Raw HTML is not allowed in ATProto knowledge documents"); + default: + throw new Error(`Unsupported Markdown token in ATProto compiler: ${token.type}`); + } + } + + return blocks.filter(({ block }) => { + if ("plaintext" in block) return block.plaintext.trim().length > 0; + if ("children" in block) return block.children.length > 0; + return true; + }); +} + +function stablePageId(slug: string): string { + const hex = createHash("sha256").update(`knowledge:${slug}`).digest("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-7${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} + +function knowledgeMarkdown(entry: KnowledgeEntry): string { + const details = [ + "AI-assisted Public Knowledge entry.", + `Claim mode: ${entry.claimMode}.`, + ...(entry.perspectiveOwner ? [`Perspective: ${entry.perspectiveOwner}.`] : []), + `Confidence: ${entry.confidence}.`, + `Updated: ${entry.updated.toISOString().slice(0, 10)}.`, + ].join(" "); + const sources = entry.sources.length > 0 + ? `\n\n## Sources\n\n${entry.sources.map((source) => `- [${source.title}](${source.url})`).join("\n")}` + : ""; + + return `# ${entry.title}\n\n_${details}_\n\n${entry.body}${sources}`; +} + +export function knowledgeEntryToStandardDocument( + entry: KnowledgeEntry, + publicationUri: string, +): { rkey: string; record: SiteStandardDocument; recordDigest: string } { + const rkey = `knowledge-${entry.slug}`; + const markdown = knowledgeMarkdown(entry); + const blocks = markdownToLeafletBlocks(markdown); + const textContent = blocks.flatMap(({ block }) => { + if ("plaintext" in block) return [block.plaintext]; + if ("children" in block) return block.children.map((item) => item.content.plaintext); + return []; + }).join("\n").slice(0, 100_000); + const publishedAt = (entry.publishedAt ?? entry.updated).toISOString(); + const record: SiteStandardDocument = { + $type: "site.standard.document", + site: publicationUri, + title: entry.title, + description: entry.summary, + publishedAt, + updatedAt: entry.updated.toISOString(), + path: `/${rkey}`, + tags: [...new Set(["knowledge", entry.kind, entry.claimMode, ...entry.topics])], + textContent, + content: { + $type: "pub.leaflet.content", + pages: [{ + $type: "pub.leaflet.pages.linearDocument", + id: stablePageId(entry.slug), + blocks, + }], + }, + }; + const recordDigest = digestStandardDocument(record); + return { rkey, record, recordDigest }; +} diff --git a/scripts/promote-knowledge.ts b/scripts/promote-knowledge.ts new file mode 100644 index 0000000..fb70c72 --- /dev/null +++ b/scripts/promote-knowledge.ts @@ -0,0 +1,93 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import matter from "gray-matter"; +import { knowledgeContentDigest, parseKnowledgeDocument } from "../src/knowledge.ts"; +import { loadKnowledgePolicy, scanKnowledgeDraft } from "./knowledge-policy.ts"; + +function option(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function main(): void { + const slug = process.argv.slice(2).find((arg) => !arg.startsWith("--")); + const reviewedBy = option("--reviewed-by"); + const reviewedContentDigest = option("--content-digest"); + const reviewReceiptDigest = option("--review-receipt-digest"); + const confirmed = process.argv.includes("--confirm-public"); + + if (!slug || !reviewedBy || !reviewedContentDigest || !reviewReceiptDigest || !confirmed) { + throw new Error( + "Usage: pnpm knowledge:promote --reviewed-by --content-digest --review-receipt-digest --confirm-public", + ); + } + + const digestPattern = /^sha256:[a-f0-9]{64}$/; + if (!digestPattern.test(reviewedContentDigest) || !digestPattern.test(reviewReceiptDigest)) { + throw new Error("content and review receipt digests must be sha256:<64 lowercase hex characters>"); + } + + const stagedPath = resolve(process.cwd(), "knowledge/staged", `${slug}.md`); + if (!existsSync(stagedPath)) throw new Error(`staged entry not found: ${slug}`); + + const source = readFileSync(stagedPath, "utf8"); + const draft = parseKnowledgeDocument(source, `${slug}.md`, true); + const actualContentDigest = knowledgeContentDigest(draft); + if (actualContentDigest !== reviewedContentDigest) { + throw new Error( + `reviewed content changed: expected ${reviewedContentDigest}, found ${actualContentDigest}`, + ); + } + const policy = loadKnowledgePolicy(); + const findings = scanKnowledgeDraft(draft.body, policy); + const blocking = findings.filter((finding) => finding.severity === "block"); + if (blocking.length > 0) throw new Error(`blocked findings remain: ${JSON.stringify(blocking)}`); + if (draft.kind === "person" && !policy.allowedPeopleSlugs.includes(draft.slug)) { + throw new Error(`person entry is not explicitly allowlisted: ${draft.slug}`); + } + if (draft.kind === "person" && draft.claimMode !== "factual") { + throw new Error("person entries must remain factual"); + } + if (draft.claimMode !== "perspective" && draft.sources.length === 0) { + throw new Error("factual and mixed entries require at least one public source"); + } + + const parsed = matter(source); + const existingPublishedPath = resolve(process.cwd(), "knowledge/published", `${slug}.md`); + const previousPublishedAt = existsSync(existingPublishedPath) + ? matter(readFileSync(existingPublishedPath, "utf8")).data.publishedAt + : undefined; + const reviewedAt = new Date().toISOString(); + const approved = { + ...parsed.data, + reviewStatus: "approved", + reviewedBy, + reviewedAt, + publishedAt: previousPublishedAt ?? reviewedAt, + reviewedContentDigest, + reviewReceiptDigest, + }; + const output = matter.stringify(`${draft.body}\n`, approved); + parseKnowledgeDocument(output, `${slug}.md`, false); + + const publishedDirectory = resolve(process.cwd(), "knowledge/published"); + mkdirSync(publishedDirectory, { recursive: true }); + const outputPath = resolve(publishedDirectory, `${slug}.md`); + writeFileSync(outputPath, output, "utf8"); + unlinkSync(stagedPath); + console.log(JSON.stringify({ + decision: "promoted", + slug, + reviewedBy, + reviewedContentDigest, + reviewReceiptDigest, + outputPath, + }, null, 2)); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/publish-all.ts b/scripts/publish-all.ts index 1a7d7b5..d95e484 100644 --- a/scripts/publish-all.ts +++ b/scripts/publish-all.ts @@ -174,7 +174,10 @@ async function main() { textContent: stripMarkdown(body).slice(0, 100000), }; if (description) record.description = description; - if (front.tags) record.tags = front.tags; + const sourceTags = Array.isArray(front.tags) + ? front.tags.filter((tag): tag is string => typeof tag === "string") + : typeof front.tags === "string" ? [front.tags] : []; + record.tags = [...new Set(["blog", ...sourceTags])]; const rkey = TID.nextStr(); diff --git a/scripts/publish.ts b/scripts/publish.ts index 0627b27..cc6762c 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -156,7 +156,10 @@ async function main() { textContent: stripMarkdown(body).slice(0, 100000), }; if (description) record.description = description; - if (front.tags) record.tags = front.tags; + const sourceTags = Array.isArray(front.tags) + ? front.tags.filter((tag): tag is string => typeof tag === "string") + : typeof front.tags === "string" ? [front.tags] : []; + record.tags = [...new Set(["blog", ...sourceTags])]; // Use TID for the rkey (standard.site requires tid keys) const rkey = TID.nextStr(); diff --git a/scripts/stage-knowledge.ts b/scripts/stage-knowledge.ts new file mode 100644 index 0000000..e6c3763 --- /dev/null +++ b/scripts/stage-knowledge.ts @@ -0,0 +1,148 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import matter from "gray-matter"; +import { + decideKnowledgeSource, + loadKnowledgePolicy, + publicizeObsidianMarkdown, + scanKnowledgeDraft, +} from "./knowledge-policy.ts"; + +interface Args { + sourcePath: string; + slug?: string; + title?: string; + summary: string; + claimMode: "factual" | "perspective" | "mixed"; + perspectiveOwner?: string; + confidence: "high" | "medium" | "low"; + sources: Array<{ title: string; url: string }>; +} + +function option(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function options(name: string): string[] { + return process.argv.flatMap((value, index) => + value === name && process.argv[index + 1] ? [process.argv[index + 1]] : [], + ); +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[’']/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function parseArgs(): Args { + const sourcePath = process.argv.slice(2).find((arg) => !arg.startsWith("--")); + if (!sourcePath) { + throw new Error( + "Usage: pnpm knowledge:stage --summary [--title ] [--slug <slug>] [--claim-mode factual|perspective|mixed] [--source title=url]", + ); + } + + const summary = option("--summary"); + if (!summary) throw new Error("--summary is required; do not stage placeholder public copy"); + + const claimMode = (option("--claim-mode") ?? "factual") as Args["claimMode"]; + const confidence = (option("--confidence") ?? "medium") as Args["confidence"]; + if (!["factual", "perspective", "mixed"].includes(claimMode)) throw new Error("invalid --claim-mode"); + if (!["high", "medium", "low"].includes(confidence)) throw new Error("invalid --confidence"); + + const sources = options("--source").map((value) => { + const separator = value.indexOf("="); + if (separator < 1) throw new Error("--source must use title=https://public.example/path"); + const title = value.slice(0, separator).trim(); + const url = value.slice(separator + 1).trim(); + new URL(url); + return { title, url }; + }); + + return { + sourcePath, + slug: option("--slug"), + title: option("--title"), + summary, + claimMode, + perspectiveOwner: option("--perspective-owner"), + confidence, + sources, + }; +} + +function main(): void { + const args = parseArgs(); + const policy = loadKnowledgePolicy(); + const decision = decideKnowledgeSource(args.sourcePath, policy); + if (decision.decision !== "stage" || !decision.mapping) { + throw new Error(`source denied: ${decision.relativePath} (${decision.reason})`); + } + + const source = readFileSync(resolve(args.sourcePath), "utf8"); + const parsed = matter(source); + const inferredTitle = + args.title ?? + (typeof parsed.data.title === "string" ? parsed.data.title : undefined) ?? + parsed.content.match(/^#\s+(.+)$/m)?.[1] ?? + basename(args.sourcePath, ".md"); + const slug = args.slug ?? slugify(inferredTitle); + const summary = args.summary; + const body = publicizeObsidianMarkdown(parsed.content, inferredTitle); + const findings = scanKnowledgeDraft(parsed.content, policy); + const blocking = findings.filter((finding) => finding.severity === "block"); + + if (blocking.length > 0) { + const details = blocking.map((finding) => `${finding.code}: ${finding.detail}`).join(", "); + throw new Error(`source contains blocked material (${details})`); + } + + const now = new Date().toISOString(); + const frontmatter = { + title: inferredTitle, + slug, + summary, + kind: decision.mapping.kind, + status: "evolving", + claimMode: args.claimMode, + ...(args.perspectiveOwner ? { perspectiveOwner: args.perspectiveOwner } : {}), + confidence: args.confidence, + topics: [...new Set([ + ...decision.mapping.topics, + ...(Array.isArray(parsed.data.tags) ? parsed.data.tags.filter((tag): tag is string => typeof tag === "string") : []), + ])], + related: [], + sources: args.sources, + aiAssisted: true, + generatedBy: "Co", + sourceDigest: `sha256:${createHash("sha256").update(source).digest("hex")}`, + updated: now, + reviewStatus: "draft", + }; + + const outputDirectory = resolve(process.cwd(), "knowledge/staged"); + mkdirSync(outputDirectory, { recursive: true }); + const outputPath = resolve(outputDirectory, `${slug}.md`); + writeFileSync(outputPath, matter.stringify(`${body}\n`, frontmatter), "utf8"); + + console.log(JSON.stringify({ + decision: "staged", + sourceClass: decision.mapping.pattern, + sourceDigest: frontmatter.sourceDigest, + outputPath, + findings, + next: "Inspect and rewrite the complete draft, add public sources, run knowledge:check, then obtain exact rendered approval before promotion.", + }, null, 2)); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/sync-knowledge-atproto.ts b/scripts/sync-knowledge-atproto.ts new file mode 100644 index 0000000..fd44d4b --- /dev/null +++ b/scripts/sync-knowledge-atproto.ts @@ -0,0 +1,322 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import "../src/env.ts"; +import { + knowledgeContentDigest, + loadKnowledgeGraph, + type KnowledgeEntry, +} from "../src/knowledge.ts"; +import { + digestStandardDocument, + knowledgeEntryToStandardDocument, + type SiteStandardDocument, +} from "./leaflet-knowledge.ts"; +import { loadKnowledgePolicy, scanKnowledgeDraft } from "./knowledge-policy.ts"; + +const COLLECTION = "site.standard.document"; +const CAMERON_DID = process.env.CAMERON_DID ?? "did:plc:gfrmhdmjvxn2sjedzboeudef"; +const PUBLICATION_URI = + process.env.PUBLICATION_URI ?? + "at://did:plc:gfrmhdmjvxn2sjedzboeudef/site.standard.publication/3md7ylshxzk2y"; +const MANIFEST_PATH = resolve(process.cwd(), "knowledge/atproto-manifest.json"); + +interface ManifestEntry { + uri: string; + cid: string; + recordDigest: string; + reviewedContentDigest: string; + reviewReceiptDigest: string; + validationStatus?: string; + commitCid?: string; + commitRev?: string; + pds: string; + syncedAt: string; +} + +interface Manifest { + version: 1; + collection: typeof COLLECTION; + publicationUri: string; + entries: Record<string, ManifestEntry>; +} + +interface RemoteRecord { + uri: string; + cid: string; + value: SiteStandardDocument; +} + +interface PlanItem { + slug: string; + rkey: string; + action: "create" | "update" | "unchanged" | "adopt" | "conflict"; + recordDigest: string; + reason: string; + remoteCid?: string; + record?: SiteStandardDocument; +} + +function loadManifest(): Manifest { + if (!existsSync(MANIFEST_PATH)) { + return { version: 1, collection: COLLECTION, publicationUri: PUBLICATION_URI, entries: {} }; + } + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) as Manifest; + if (manifest.version !== 1 || manifest.collection !== COLLECTION) { + throw new Error("Unsupported ATProto knowledge manifest"); + } + if (manifest.publicationUri !== PUBLICATION_URI) { + throw new Error("Manifest publication URI does not match configured publication"); + } + return manifest; +} + +function digestRecord(record: SiteStandardDocument): string { + return digestStandardDocument(record); +} + +async function resolvePds(did: string): Promise<string> { + if (process.env.ATP_SERVICE) return process.env.ATP_SERVICE.replace(/\/$/, ""); + const response = await fetch(`https://plc.directory/${did}`); + if (!response.ok) throw new Error(`Could not resolve DID document: ${response.status}`); + const document = await response.json() as { + service?: Array<{ id?: string; type?: string; serviceEndpoint?: string }>; + }; + const service = document.service?.find((item) => + item.id === "#atproto_pds" || item.type === "AtprotoPersonalDataServer" + ); + if (!service?.serviceEndpoint) throw new Error("DID document has no ATProto PDS endpoint"); + return service.serviceEndpoint.replace(/\/$/, ""); +} + +async function getRemoteRecord( + pds: string, + rkey: string, +): Promise<RemoteRecord | null> { + const query = new URLSearchParams({ repo: CAMERON_DID, collection: COLLECTION, rkey }); + const response = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?${query}`); + if (response.status === 400) { + const error = await response.json().catch(() => ({})) as { error?: string }; + if (error.error === "RecordNotFound") return null; + } + if (!response.ok) throw new Error(`getRecord ${rkey} failed: ${response.status} ${await response.text()}`); + return await response.json() as RemoteRecord; +} + +function classify( + entry: KnowledgeEntry, + manifest: Manifest, + remote: RemoteRecord | null, +): PlanItem { + const { rkey, record, recordDigest } = knowledgeEntryToStandardDocument(entry, PUBLICATION_URI); + const known = manifest.entries[entry.slug]; + + if (!remote) { + if (known) { + return { slug: entry.slug, rkey, action: "conflict", recordDigest, reason: "manifest expects a remote record, but it is missing" }; + } + return { slug: entry.slug, rkey, action: "create", recordDigest, reason: "no remote record exists", record }; + } + + const remoteDigest = digestRecord(remote.value); + if (remoteDigest === recordDigest) { + return { + slug: entry.slug, + rkey, + action: known ? "unchanged" : "adopt", + recordDigest, + reason: known ? "remote record already matches approved file" : "identical remote record can be adopted into the manifest", + remoteCid: remote.cid, + record, + }; + } + + if (!known) { + return { + slug: entry.slug, + rkey, + action: "conflict", + recordDigest, + reason: "a different unmanaged remote record already uses this rkey", + remoteCid: remote.cid, + }; + } + if (known.cid !== remote.cid) { + return { + slug: entry.slug, + rkey, + action: "conflict", + recordDigest, + reason: "remote CID changed outside this reconciler", + remoteCid: remote.cid, + }; + } + return { + slug: entry.slug, + rkey, + action: "update", + recordDigest, + reason: "approved local content changed and the remote CID still matches the manifest", + remoteCid: remote.cid, + record, + }; +} + +async function createSession(pds: string): Promise<{ accessJwt: string; did: string }> { + const identifier = process.env.ATP_IDENTIFIER ?? CAMERON_DID; + const password = process.env.CAMERON_BSKY_APP_PASSWORD ?? process.env.ATP_PASSWORD; + if (!password) { + throw new Error("CAMERON_BSKY_APP_PASSWORD or ATP_PASSWORD is required for --apply"); + } + const response = await fetch(`${pds}/xrpc/com.atproto.server.createSession`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier, password }), + }); + if (!response.ok) throw new Error(`createSession failed: ${response.status} ${await response.text()}`); + const session = await response.json() as { accessJwt: string; did: string }; + if (session.did !== CAMERON_DID) { + throw new Error(`Refusing wrong identity: authenticated ${session.did}, expected ${CAMERON_DID}`); + } + return session; +} + +async function putRecord( + pds: string, + accessJwt: string, + item: PlanItem, +): Promise<{ + uri: string; + cid: string; + validationStatus?: string; + commit?: { cid?: string; rev?: string }; +}> { + if (!item.record) throw new Error(`No record payload for ${item.slug}`); + const response = await fetch(`${pds}/xrpc/com.atproto.repo.putRecord`, { + method: "POST", + headers: { Authorization: `Bearer ${accessJwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + repo: CAMERON_DID, + collection: COLLECTION, + rkey: item.rkey, + record: item.record, + ...(item.action === "update" ? { swapRecord: item.remoteCid } : {}), + }), + }); + if (!response.ok) throw new Error(`putRecord ${item.slug} failed: ${response.status} ${await response.text()}`); + return await response.json() as { + uri: string; + cid: string; + validationStatus?: string; + commit?: { cid?: string; rev?: string }; + }; +} + +async function main(): Promise<void> { + const apply = process.argv.includes("--apply"); + const includeDrafts = process.argv.includes("--include-drafts"); + const printRecords = process.argv.includes("--json"); + const slugIndex = process.argv.indexOf("--slug"); + const selectedSlug = slugIndex >= 0 ? process.argv[slugIndex + 1] : undefined; + + if (apply && includeDrafts) throw new Error("--include-drafts is preview-only and cannot be combined with --apply"); + + const graph = await loadKnowledgeGraph({ includeDrafts }); + if (graph.errors.length > 0) throw new Error(`knowledge graph errors: ${graph.errors.join("; ")}`); + const entries = selectedSlug + ? graph.entries.filter((entry) => entry.slug === selectedSlug) + : graph.entries; + if (selectedSlug && entries.length === 0) throw new Error(`knowledge entry not found: ${selectedSlug}`); + if (apply && entries.length === 0) throw new Error("refusing an empty apply set"); + + const policy = loadKnowledgePolicy(); + for (const entry of entries) { + const blocking = scanKnowledgeDraft(entry.body, policy).filter( + (finding) => finding.severity === "block", + ); + if (blocking.length > 0) { + throw new Error(`${entry.slug}: blocking policy findings: ${JSON.stringify(blocking)}`); + } + if (entry.kind === "person" && !policy.allowedPeopleSlugs.includes(entry.slug)) { + throw new Error(`${entry.slug}: person entry is not explicitly allowlisted`); + } + if (entry.kind === "person" && entry.claimMode !== "factual") { + throw new Error(`${entry.slug}: person entries must remain factual`); + } + if (!entry.draft && knowledgeContentDigest(entry) !== entry.reviewedContentDigest) { + throw new Error(`${entry.slug}: approved content no longer matches its reviewed digest`); + } + } + + const manifest = loadManifest(); + const pds = await resolvePds(CAMERON_DID); + const plans: PlanItem[] = []; + for (const entry of entries) { + const { rkey } = knowledgeEntryToStandardDocument(entry, PUBLICATION_URI); + plans.push(classify(entry, manifest, await getRemoteRecord(pds, rkey))); + } + + const presentSlugs = new Set(graph.entries.filter((entry) => !entry.draft).map((entry) => entry.slug)); + const orphanedManifestEntries = Object.keys(manifest.entries).filter((slug) => !presentSlugs.has(slug)); + + if (!apply) { + console.log(JSON.stringify({ + mode: "dry-run", + pds, + publicationUri: PUBLICATION_URI, + plans: plans.map((item) => printRecords ? item : { ...item, record: undefined }), + orphanedManifestEntries, + deletionPolicy: "warn-only", + }, null, 2)); + return; + } + + const conflicts = plans.filter((item) => item.action === "conflict"); + if (conflicts.length > 0) throw new Error(`refusing conflicts: ${JSON.stringify(conflicts)}`); + const session = await createSession(pds); + + for (const item of plans) { + let receipt: { + uri: string; + cid: string; + validationStatus?: string; + commit?: { cid?: string; rev?: string }; + }; + if (item.action === "create" || item.action === "update") { + receipt = await putRecord(pds, session.accessJwt, item); + const verified = await getRemoteRecord(pds, item.rkey); + if (!verified || verified.cid !== receipt.cid || digestRecord(verified.value) !== item.recordDigest) { + throw new Error(`post-write verification failed for ${item.slug}`); + } + } else { + const remote = await getRemoteRecord(pds, item.rkey); + if (!remote) throw new Error(`remote record disappeared for ${item.slug}`); + receipt = { uri: remote.uri, cid: remote.cid }; + } + + const entry = graph.bySlug.get(item.slug)!; + manifest.entries[item.slug] = { + ...manifest.entries[item.slug], + uri: receipt.uri, + cid: receipt.cid, + recordDigest: item.recordDigest, + reviewedContentDigest: entry.reviewedContentDigest!, + reviewReceiptDigest: entry.reviewReceiptDigest!, + ...(receipt.validationStatus ? { validationStatus: receipt.validationStatus } : {}), + ...(receipt.commit?.cid ? { commitCid: receipt.commit.cid } : {}), + ...(receipt.commit?.rev ? { commitRev: receipt.commit.rev } : {}), + pds, + syncedAt: new Date().toISOString(), + }; + writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + console.log(`${item.action}: ${receipt.uri} ${receipt.cid}`); + } + + if (orphanedManifestEntries.length > 0) { + console.warn(`Not deleting absent local entries: ${orphanedManifestEntries.join(", ")}`); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/code-activity.ts b/src/code-activity.ts new file mode 100644 index 0000000..b3169af --- /dev/null +++ b/src/code-activity.ts @@ -0,0 +1,268 @@ +import { cacheGet, cacheSet } from "./cache.ts"; + +export interface ActivityDay { + date: string; + count: number; + level: number; +} + +export interface ActivitySeries { + total: number; + period: string; + days: ActivityDay[]; +} + +export interface TangledRepository { + repoDid: string; + name: string; + description: string; + website: string | null; + url: string; +} + +export interface CodeActivityData { + github: ActivitySeries | null; + tangled: ActivitySeries | null; + tangledPinned: TangledRepository[]; +} + +interface DidDocument { + service?: Array<{ + type?: string; + serviceEndpoint?: string; + }>; +} + +interface AtRecord<T = Record<string, unknown>> { + uri: string; + value: T; +} + +interface TangledProfileRecord { + pinnedRepositories?: unknown; +} + +interface TangledRepoRecord { + repoDid?: unknown; + name?: unknown; + description?: unknown; + website?: unknown; + createdAt?: unknown; +} + +interface GetRecordResponse<T> { + value?: T; +} + +interface ListRecordsResponse<T> { + records?: Array<AtRecord<T>>; +} + +const CAMERON_DID = "did:plc:gfrmhdmjvxn2sjedzboeudef"; +const TANGLED_HANDLE = "cameron.stream"; +const TID_RKEY = /^3[a-z2-7]{12}$/; + +const REQUEST_HEADERS = { + "User-Agent": "cameron.stream code activity", + Accept: "text/html", +}; + +function contributionCount(label: string): number { + if (/^no contributions/i.test(label.trim())) return 0; + const match = label.match(/([\d,]+) contribution/i); + return match ? Number(match[1].replaceAll(",", "")) : 0; +} + +export function parseGithubActivity(html: string): ActivitySeries | null { + const totalMatch = html.match( + /<h2[^>]*id="js-contribution-activity-description"[^>]*>[\s\S]*?([\d,]+)[\s\S]*?contributions[\s\S]*?<\/h2>/i + ); + const days: ActivityDay[] = []; + const dayPattern = + /<td[^>]*data-date="([^"]+)"[^>]*data-level="([0-4])"[^>]*><\/td>\s*<tool-tip[^>]*>([^<]+)<\/tool-tip>/gi; + + for (const match of html.matchAll(dayPattern)) { + days.push({ + date: match[1], + level: Number(match[2]), + count: contributionCount(match[3]), + }); + } + + if (!totalMatch || days.length < 300) return null; + days.sort((a, b) => a.date.localeCompare(b.date)); + + return { + total: Number(totalMatch[1].replaceAll(",", "")), + period: "in the last year", + days, + }; +} + +function tangledLevel(count: number): number { + if (count === 0) return 0; + if (count === 1) return 1; + if (count <= 3) return 2; + if (count <= 7) return 3; + return 4; +} + +export function parseTangledActivity(html: string): ActivitySeries | null { + const totalMatch = html.match(/Punchcard[\s\S]{0,500}?([\d,]+) commits/i); + const days: ActivityDay[] = []; + const dayPattern = /title="(\d{4}-\d{2}-\d{2}): ([\d,]+) commits"/gi; + + for (const match of html.matchAll(dayPattern)) { + const count = Number(match[2].replaceAll(",", "")); + days.push({ + date: match[1], + count, + level: tangledLevel(count), + }); + } + + if (!totalMatch || days.length < 300) return null; + days.sort((a, b) => a.date.localeCompare(b.date)); + + return { + total: Number(totalMatch[1].replaceAll(",", "")), + period: `in ${days[0].date.slice(0, 4)}`, + days, + }; +} + +async function fetchText(url: string): Promise<string | null> { + try { + const response = await fetch(url, { + headers: REQUEST_HEADERS, + signal: AbortSignal.timeout(8_000), + }); + return response.ok ? await response.text() : null; + } catch { + return null; + } +} + +async function fetchJson<T>(url: string): Promise<T | null> { + try { + const response = await fetch(url, { + headers: { + "User-Agent": "cameron.stream code activity", + Accept: "application/json", + }, + signal: AbortSignal.timeout(8_000), + }); + return response.ok ? await response.json() as T : null; + } catch { + return null; + } +} + +function recordKey(uri: string): string { + return uri.slice(uri.lastIndexOf("/") + 1); +} + +function repositoryName(record: AtRecord<TangledRepoRecord>): string | null { + const rkey = recordKey(record.uri); + if (!TID_RKEY.test(rkey)) return rkey; + return typeof record.value.name === "string" && record.value.name + ? record.value.name + : null; +} + +function repositoryNameQuality(record: AtRecord<TangledRepoRecord>): number { + return TID_RKEY.test(recordKey(record.uri)) ? 1 : 2; +} + +function createdAt(record: AtRecord<TangledRepoRecord>): number { + if (typeof record.value.createdAt !== "string") return 0; + const timestamp = Date.parse(record.value.createdAt); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +export function parseTangledPinnedRepositories( + profile: TangledProfileRecord, + records: Array<AtRecord<TangledRepoRecord>>, +): TangledRepository[] { + if (!Array.isArray(profile.pinnedRepositories)) return []; + + return profile.pinnedRepositories.flatMap((repoDid) => { + if (typeof repoDid !== "string") return []; + + const candidates = records + .filter((record) => record.value.repoDid === repoDid) + .filter((record) => repositoryName(record) !== null) + .sort((a, b) => { + const quality = repositoryNameQuality(b) - repositoryNameQuality(a); + return quality || createdAt(b) - createdAt(a); + }); + const selected = candidates[0]; + if (!selected) return []; + + const name = repositoryName(selected); + if (!name) return []; + + return [{ + repoDid, + name, + description: typeof selected.value.description === "string" + ? selected.value.description + : "", + website: typeof selected.value.website === "string" && selected.value.website + ? selected.value.website + : null, + url: `https://tangled.org/${TANGLED_HANDLE}/${encodeURIComponent(name)}`, + }]; + }); +} + +async function getTangledPinnedRepositories(): Promise<TangledRepository[]> { + const didDocument = await fetchJson<DidDocument>( + `https://plc.directory/${CAMERON_DID}`, + ); + const pds = didDocument?.service?.find( + (service) => service.type === "AtprotoPersonalDataServer", + )?.serviceEndpoint; + if (!pds) return []; + + const query = (collection: string) => + `repo=${encodeURIComponent(CAMERON_DID)}&collection=${encodeURIComponent(collection)}`; + const [profileResponse, repositoriesResponse] = await Promise.all([ + fetchJson<GetRecordResponse<TangledProfileRecord>>( + `${pds}/xrpc/com.atproto.repo.getRecord?${query("sh.tangled.actor.profile")}&rkey=self`, + ), + fetchJson<ListRecordsResponse<TangledRepoRecord>>( + `${pds}/xrpc/com.atproto.repo.listRecords?${query("sh.tangled.repo")}&limit=100`, + ), + ]); + + if (!profileResponse?.value || !repositoriesResponse?.records) return []; + return parseTangledPinnedRepositories( + profileResponse.value, + repositoriesResponse.records, + ); +} + +export async function getCodeActivity(): Promise<CodeActivityData> { + const cacheKey = "code-activity:v2"; + const cached = await cacheGet(cacheKey); + if (cached !== undefined) return cached as CodeActivityData; + + const [githubHtml, tangledHtml, tangledPinned] = await Promise.all([ + fetchText("https://github.com/users/just-cameron/contributions"), + fetchText("https://tangled.org/cameron.stream"), + getTangledPinnedRepositories(), + ]); + const activity = { + github: githubHtml ? parseGithubActivity(githubHtml) : null, + tangled: tangledHtml ? parseTangledActivity(tangledHtml) : null, + tangledPinned, + }; + + await cacheSet(cacheKey, activity, { + life: activity.github && activity.tangled && activity.tangledPinned.length > 0 + ? "hours" + : "minutes", + }); + return activity; +} diff --git a/src/components/artifacts.tsx b/src/components/artifacts.tsx index af76235..5c0dbe5 100644 --- a/src/components/artifacts.tsx +++ b/src/components/artifacts.tsx @@ -1,4 +1,31 @@ const artifacts = [ + { + number: "004", + href: "/artifacts/thoughtstore-and-jazz", + title: "The Promise and the Engine", + kind: "ARCHITECTURE", + description: + "A concrete explanation of why ThoughtStore owns persistence semantics while Jazz remains the first engine that fulfills them.", + status: "DECISION", + }, + { + number: "003", + href: "/artifacts/what-fits-in-a-lora", + title: "What Fits in a LoRA?", + kind: "EXPERIMENT", + description: + "A testable hypothesis about content topology: why repeated examples of one rule may be easier to absorb than fewer unrelated corrections.", + status: "PROPOSED", + }, + { + number: "002", + href: "/artifacts/lora-without-regret", + title: "LoRA Without Regret", + kind: "FIELD GUIDE", + description: + "A working model of low-rank adaptation: capacity, placement, optimization dynamics, and the boundary where FullFT pulls away.", + status: "ACTIVE", + }, { number: "001", href: "/artifacts/tinker", diff --git a/src/components/blog-index.tsx b/src/components/blog-index.tsx index 90093ff..3ce0314 100644 --- a/src/components/blog-index.tsx +++ b/src/components/blog-index.tsx @@ -1,57 +1,153 @@ -// stream.cameron.BlogIndex — Lists all blog posts. +// stream.cameron.BlogIndex — Compact index for public writing records. -import { raw } from "hono/html"; -import { listBlogPosts, getProfile } from "../data.ts"; +import { listBlogPosts, getProfile, type BlogPost } from "../data.ts"; import { PageNav } from "./page-nav.tsx"; +const fullDate = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", +}); + +const shortDate = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + timeZone: "UTC", +}); + +function postDate(post: BlogPost): Date { + return new Date(post.publishedAt); +} + +function postHref(post: BlogPost): string { + return `/${post.slug}`; +} + +function RecentPost({ post }: { post: BlogPost }) { + const date = postDate(post); + + return ( + <article class="blog-recent-post"> + <a href={postHref(post)}> + <time class="blog-recent-date" datetime={post.publishedAt}>{fullDate.format(date)}</time> + <div class="blog-recent-copy"> + <h3>{post.title}</h3> + {post.description && <p>{post.description}</p>} + </div> + <span class="blog-row-arrow" aria-hidden="true">→</span> + </a> + </article> + ); +} + +function groupByYear(posts: BlogPost[]): Array<[string, BlogPost[]]> { + const groups = new Map<string, BlogPost[]>(); + + for (const post of posts) { + const year = String(postDate(post).getUTCFullYear()); + const group = groups.get(year) ?? []; + group.push(post); + groups.set(year, group); + } + + return Array.from(groups.entries()); +} + export async function BlogIndex() { const [posts, profile] = await Promise.all([ listBlogPosts(), getProfile(), ]); - const postsHtml = posts - .map((post) => { - const date = new Date(post.publishedAt).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); - const tags = (post.tags ?? []) - .map( - (t) => - `<span style="font-size:0.75rem;color:var(--site-text-muted);background:var(--site-code-bg);padding:0.1em 0.5em;border-radius:3px">${escapeHtml(t)}</span>` - ) - .join(" "); - - return `<div class="post-item"> - <div class="post-date">${date}</div> - <a href="/${post.slug}" class="post-title">${escapeHtml(post.title)}</a> - ${post.description ? `<div class="post-summary">${escapeHtml(post.description)}</div>` : ""} - ${tags ? `<div style="margin-top:0.4em;display:flex;gap:0.4em;flex-wrap:wrap">${tags}</div>` : ""} - </div>`; - }) - .join(""); + const lead = posts[0]; + const recent = posts.slice(1, 5); + const archive = groupByYear(posts.slice(5)); + const firstYear = posts.at(-1) + ? postDate(posts.at(-1)!).getUTCFullYear() + : undefined; + const latestYear = lead ? postDate(lead).getUTCFullYear() : undefined; return ( - <div> + <div class="blog-index-shell"> <PageNav profile={profile} active="blog" /> - <h1 class="page-title">Blog</h1> + <header class="blog-index-header"> + <div> + <h1>Blog</h1> + </div> + {posts.length > 0 && ( + <p class="blog-index-summary"> + {posts.length} entries <span aria-hidden="true">·</span>{" "} + {firstYear === latestYear ? latestYear : `${firstYear}–${latestYear}`} + </p> + )} + </header> - {posts.length === 0 ? ( - <p style="color:var(--site-text-secondary)">No posts yet.</p> + {lead ? ( + <> + <article class="blog-lead"> + <a href={postHref(lead)}> + <div class="blog-lead-copy"> + <div class="blog-lead-meta"> + <span>Latest</span> + <time datetime={lead.publishedAt}>{fullDate.format(postDate(lead))}</time> + </div> + <h2>{lead.title}</h2> + {lead.description && <p>{lead.description}</p>} + </div> + <span class="blog-lead-arrow" aria-hidden="true">→</span> + </a> + </article> + + {recent.length > 0 && ( + <section class="blog-recent" aria-labelledby="blog-recent-title"> + <div class="blog-index-section-heading"> + <h2 id="blog-recent-title">Recent</h2> + </div> + <div class="blog-recent-grid"> + {recent.map((post) => ( + <RecentPost post={post} key={post.uri} /> + ))} + </div> + </section> + )} + + {archive.length > 0 && ( + <section class="blog-archive" aria-labelledby="blog-archive-title"> + <div class="blog-index-section-heading"> + <h2 id="blog-archive-title">Archive</h2> + </div> + + <div class="blog-archive-years"> + {archive.map(([year, yearPosts]) => ( + <section class="blog-archive-year" aria-labelledby={`blog-year-${year}`} key={year}> + <h3 id={`blog-year-${year}`}>{year}</h3> + <ol> + {yearPosts.map((post) => { + const date = postDate(post); + return ( + <li key={post.uri}> + <a href={postHref(post)}> + <time datetime={post.publishedAt}>{shortDate.format(date)}</time> + <span class="blog-archive-copy"> + <strong>{post.title}</strong> + </span> + <span class="blog-archive-arrow" aria-hidden="true">→</span> + </a> + </li> + ); + })} + </ol> + </section> + ))} + </div> + </section> + )} + </> ) : ( - raw(postsHtml) + <p class="blog-index-empty">No posts yet.</p> )} </div> ); } - -function escapeHtml(str: string): string { - return str - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """); -} diff --git a/src/components/blog-post.tsx b/src/components/blog-post.tsx index 0668696..cd089a1 100644 --- a/src/components/blog-post.tsx +++ b/src/components/blog-post.tsx @@ -6,6 +6,7 @@ import { renderMarkdown } from "../markdown.ts"; import { cacheGet, cacheSet } from "../cache.ts"; import { MarginPanel } from "./margin.tsx"; import { PageNav } from "./page-nav.tsx"; +import { ProtocolLinks } from "./protocol-links.tsx"; export async function BlogPost({ slug, @@ -35,6 +36,7 @@ export async function BlogPost({ year: "numeric", month: "long", day: "numeric", + timeZone: "UTC", }); const markdownCacheKey = `markdown:${post.uri}:${post.updatedAt ?? post.publishedAt}`; @@ -77,13 +79,11 @@ export async function BlogPost({ <MarginPanel pageUrl={pageUrl} /> <footer class="page-footer"> - <a - href={`https://pdsls.dev/${atUri}`} - target="_blank" - rel="noopener" - > - View record on PDS - </a> + <ProtocolLinks + atUri={atUri} + leafletUrl={`https://cameron.leaflet.pub/${encodeURIComponent(post.slug)}`} + avatarUrl={profile?.avatar} + /> </footer> </article> </div> diff --git a/src/components/code-activity.tsx b/src/components/code-activity.tsx new file mode 100644 index 0000000..9761203 --- /dev/null +++ b/src/components/code-activity.tsx @@ -0,0 +1,150 @@ +import { getCodeActivity, type ActivitySeries } from "../code-activity.ts"; +import { getProfile } from "../data.ts"; +import { PageNav } from "./page-nav.tsx"; + +function ActivityUnavailable({ source }: { source: string }) { + return ( + <p class="code-activity-unavailable"> + {source} activity is temporarily unavailable. + </p> + ); +} + +function CalendarGraph({ + activity, + source, + noun, +}: { + activity: ActivitySeries; + source: string; + noun: string; +}) { + const firstDay = activity.days[0]?.date; + const leadingDays = firstDay + ? new Date(`${firstDay}T00:00:00Z`).getUTCDay() + : 0; + + return ( + <div class="code-graph-scroll"> + <div + class="code-calendar-graph" + role="img" + aria-label={`${activity.total.toLocaleString("en-US")} ${source} ${noun}s ${activity.period}`} + > + {Array.from({ length: leadingDays }, (_, index) => ( + <span class="code-activity-day code-activity-day-empty" aria-hidden="true" key={`empty-${index}`} /> + ))} + {activity.days.map((day) => ( + <span + class={`code-activity-day activity-level-${day.level}`} + title={`${day.date}: ${day.count} ${noun}${day.count === 1 ? "" : "s"}`} + aria-hidden="true" + key={day.date} + /> + ))} + </div> + </div> + ); +} + +export async function CodeActivity() { + const [profile, activity] = await Promise.all([ + getProfile(), + getCodeActivity(), + ]); + + return ( + <div class="code-page"> + <PageNav profile={profile} active="code" /> + + <header class="code-page-header"> + <h1 class="page-title">Code</h1> + <p class="page-subtitle"> + Public work across{" "} + <a href="https://tangled.org/cameron.stream" target="_blank" rel="noopener"> + Tangled + </a>{" "} + and{" "} + <a href="https://github.com/just-cameron" target="_blank" rel="noopener"> + GitHub + </a> + . + </p> + </header> + + <main class="code-activity-list"> + <section class="code-activity-section" aria-labelledby="tangled-activity-title"> + <div class="code-activity-heading"> + <div> + <h2 id="tangled-activity-title"> + <a href="https://tangled.org/cameron.stream" target="_blank" rel="noopener"> + Tangled <span aria-hidden="true">↗</span> + </a> + </h2> + <p>cameron.stream</p> + </div> + {activity.tangled && ( + <p class="code-activity-total"> + <strong>{activity.tangled.total.toLocaleString("en-US")}</strong> + {" "}commits {activity.tangled.period} + </p> + )} + </div> + {activity.tangled ? ( + <CalendarGraph activity={activity.tangled} source="Tangled" noun="commit" /> + ) : ( + <ActivityUnavailable source="Tangled" /> + )} + {activity.tangledPinned.length > 0 && ( + <div class="code-pinned-repositories"> + <div class="code-pinned-heading"> + <h3>Pinned repositories</h3> + <p>Selected on Tangled</p> + </div> + <div class="code-pinned-list"> + {activity.tangledPinned.map((repository) => ( + <a + class="code-pinned-repository" + href={repository.url} + target="_blank" + rel="noopener" + key={repository.repoDid} + > + <span class="code-pinned-repository-name"> + {repository.name} <span aria-hidden="true">↗</span> + </span> + {repository.description && <span>{repository.description}</span>} + </a> + ))} + </div> + </div> + )} + </section> + + <section class="code-activity-section" aria-labelledby="github-activity-title"> + <div class="code-activity-heading"> + <div> + <h2 id="github-activity-title"> + <a href="https://github.com/just-cameron" target="_blank" rel="noopener"> + GitHub <span aria-hidden="true">↗</span> + </a> + </h2> + <p>@just-cameron</p> + </div> + {activity.github && ( + <p class="code-activity-total"> + <strong>{activity.github.total.toLocaleString("en-US")}</strong> + {" "}contributions {activity.github.period} + </p> + )} + </div> + {activity.github ? ( + <CalendarGraph activity={activity.github} source="GitHub" noun="contribution" /> + ) : ( + <ActivityUnavailable source="GitHub" /> + )} + </section> + </main> + </div> + ); +} diff --git a/src/components/endorsements.tsx b/src/components/endorsements.tsx index 9bf96d7..5b32f8e 100644 --- a/src/components/endorsements.tsx +++ b/src/components/endorsements.tsx @@ -11,7 +11,7 @@ export async function Endorsements() { return ( <div> - <PageNav profile={profile} active="endorsements" /> + <PageNav profile={profile} active="other" /> <h1 class="page-title">Endorsements</h1> <p class="page-subtitle"> diff --git a/src/components/knowledge-document.tsx b/src/components/knowledge-document.tsx new file mode 100644 index 0000000..58b08ef --- /dev/null +++ b/src/components/knowledge-document.tsx @@ -0,0 +1,33 @@ +import { raw } from "hono/html"; +import { getProfile, type BlogPost } from "../data.ts"; +import { renderMarkdown } from "../markdown.ts"; +import { PageNav } from "./page-nav.tsx"; +import { ProtocolLinks } from "./protocol-links.tsx"; + +export async function KnowledgeDocument({ document }: { document: BlogPost }) { + const [profile, html] = await Promise.all([ + getProfile(), + renderMarkdown(document.body), + ]); + + return ( + <div class="knowledge-shell"> + <PageNav profile={profile} active="knowledge" /> + <main class="knowledge-entry"> + <a class="knowledge-back" href="/knowledge">← Knowledge</a> + <header> + <h1>{document.title}</h1> + {document.description && <p class="knowledge-entry-summary">{document.description}</p>} + </header> + <div class="blog-content knowledge-entry-body">{raw(html)}</div> + <footer class="knowledge-entry-footer knowledge-entry-protocol-footer"> + <ProtocolLinks + atUri={document.uri} + leafletUrl={`https://cameron.leaflet.pub/knowledge-${encodeURIComponent(document.slug)}`} + avatarUrl={profile?.avatar} + /> + </footer> + </main> + </div> + ); +} diff --git a/src/components/knowledge-entry.tsx b/src/components/knowledge-entry.tsx new file mode 100644 index 0000000..b0a9449 --- /dev/null +++ b/src/components/knowledge-entry.tsx @@ -0,0 +1,105 @@ +import { raw } from "hono/html"; +import { getProfile } from "../data.ts"; +import { + formatKnowledgeDate, + loadKnowledgeGraph, + type KnowledgeEntry as KnowledgeEntryData, +} from "../knowledge.ts"; +import { renderMarkdown } from "../markdown.ts"; +import { PageNav } from "./page-nav.tsx"; +import { ProtocolLinks } from "./protocol-links.tsx"; + +function EntryLink({ entry }: { entry: KnowledgeEntryData }) { + return <a href={`/knowledge/${entry.slug}`}>{entry.title}</a>; +} + +export async function getKnowledgeEntry(slug: string): Promise<KnowledgeEntryData | undefined> { + return (await loadKnowledgeGraph()).bySlug.get(slug); +} + +export async function KnowledgeEntry({ entry }: { entry: KnowledgeEntryData }) { + const [profile, graph, html] = await Promise.all([ + getProfile(), + loadKnowledgeGraph(), + renderMarkdown(entry.body), + ]); + + const related = entry.related + .map((slug) => graph.bySlug.get(slug)) + .filter((item): item is KnowledgeEntryData => Boolean(item)); + const backlinks = entry.backlinks + .map((slug) => graph.bySlug.get(slug)) + .filter((item): item is KnowledgeEntryData => Boolean(item)); + + return ( + <div class="knowledge-shell"> + <PageNav profile={profile} active="knowledge" /> + + <main class="knowledge-entry"> + <a class="knowledge-back" href="/knowledge">← Knowledge</a> + {entry.draft && <div class="knowledge-draft-banner">Draft preview. This entry is not publishable yet.</div>} + + <header> + <h1>{entry.title}</h1> + <p class="knowledge-entry-summary">{entry.summary}</p> + </header> + + <dl class="knowledge-entry-facts"> + <div><dt>Type</dt><dd>{entry.kind}</dd></div> + <div><dt>Mode</dt><dd>{entry.claimMode}</dd></div> + {entry.perspectiveOwner && <div><dt>Perspective</dt><dd>{entry.perspectiveOwner}</dd></div>} + <div><dt>Confidence</dt><dd>{entry.confidence}</dd></div> + <div><dt>Status</dt><dd>{entry.status}</dd></div> + <div><dt>Updated</dt><dd>{formatKnowledgeDate(entry.updated)}</dd></div> + </dl> + + <div class="blog-content knowledge-entry-body">{raw(html)}</div> + + {entry.sources.length > 0 && ( + <section class="knowledge-entry-section"> + <h2>Sources</h2> + <ol class="knowledge-sources"> + {entry.sources.map((source) => ( + <li><a href={source.url} target="_blank" rel="noopener">{source.title}</a></li> + ))} + </ol> + </section> + )} + + {(related.length > 0 || backlinks.length > 0) && ( + <section class="knowledge-entry-section knowledge-connections"> + <h2>Connections</h2> + {related.length > 0 && ( + <div><h3>Related</h3><ul>{related.map((item) => <li><EntryLink entry={item} /></li>)}</ul></div> + )} + {backlinks.length > 0 && ( + <div><h3>Linked here</h3><ul>{backlinks.map((item) => <li><EntryLink entry={item} /></li>)}</ul></div> + )} + </section> + )} + + <footer class="knowledge-entry-footer"> + <p> + AI-assisted entry generated by {entry.generatedBy}. {entry.draft + ? "Awaiting explicit public review." + : `Reviewed by ${entry.reviewedBy}.`} + </p> + <p> + <a href="https://bsky.app/profile/did:plc:gfrmhdmjvxn2sjedzboeudef" target="_blank" rel="noopener"> + Suggest a correction ↗ + </a> + </p> + </footer> + + {!entry.draft && ( + <ProtocolLinks + className="knowledge-protocol-links" + atUri={`at://${profile?.did ?? "did:plc:gfrmhdmjvxn2sjedzboeudef"}/site.standard.document/knowledge-${entry.slug}`} + leafletUrl={`https://cameron.leaflet.pub/knowledge-${encodeURIComponent(entry.slug)}`} + avatarUrl={profile?.avatar} + /> + )} + </main> + </div> + ); +} diff --git a/src/components/knowledge-index.tsx b/src/components/knowledge-index.tsx new file mode 100644 index 0000000..04f1ef9 --- /dev/null +++ b/src/components/knowledge-index.tsx @@ -0,0 +1,152 @@ +import { raw } from "hono/html"; +import { getKnowledgeNow, getProfile } from "../data.ts"; +import { formatKnowledgeDate, loadKnowledgeGraph } from "../knowledge.ts"; +import { renderMarkdown } from "../markdown.ts"; +import { Library } from "./library.tsx"; +import { PageNav } from "./page-nav.tsx"; + +function kindLabel(kind: string): string { + return kind.charAt(0).toUpperCase() + kind.slice(1); +} + +export async function KnowledgeIndex() { + const [profile, graph, remoteNow] = await Promise.all([ + getProfile(), + loadKnowledgeGraph(), + getKnowledgeNow(), + ]); + const localNow = graph.bySlug.get("now"); + const entries = graph.entries.filter( + (entry) => entry.slug !== "now" && !entry.slug.startsWith("daily-"), + ); + const topics = new Set(entries.flatMap((entry) => entry.topics)); + const nowBody = localNow?.body ?? remoteNow?.body; + const nowHtml = nowBody ? await renderMarkdown(nowBody) : undefined; + const nowDate = localNow?.updated ?? (remoteNow ? new Date(remoteNow.publishedAt) : undefined); + + return ( + <div class="knowledge-shell"> + <PageNav profile={profile} active="knowledge" /> + + <header class="knowledge-header"> + <h1>Knowledge</h1> + <p> + A public daily note, linked knowledge entries, and the things I have + saved along the way. + </p> + </header> + + <nav class="knowledge-local-nav" aria-label="Knowledge sections"> + <a href="#now">NOW</a> + <a href="#entries">Entries</a> + <a href="#links">Links</a> + <a href="#annotations">Annotations</a> + </nav> + + <aside class="knowledge-notice" aria-label="Editorial notice"> + <strong>This page is AI-maintained.</strong> + <p> + We make a best effort to distinguish fact from perspective and personal + judgement. If something is off, please{" "} + <a href="https://bsky.app/profile/did:plc:gfrmhdmjvxn2sjedzboeudef" target="_blank" rel="noopener"> + tell me + </a>. + </p> + </aside> + + {graph.errors.length > 0 && ( + <section class="knowledge-error" aria-live="polite"> + <strong>Some entries could not be loaded.</strong> + <ul>{graph.errors.map((error) => <li>{error}</li>)}</ul> + </section> + )} + + <section class="knowledge-now" id="now" aria-labelledby="knowledge-now-title"> + <header> + <div> + <h2 id="knowledge-now-title">NOW</h2> + <p class="knowledge-now-description"> + {localNow?.summary ?? remoteNow?.description ?? "What I am currently thinking about."} + </p> + </div> + <div class="knowledge-now-state"> + {localNow?.draft && <span class="knowledge-draft">Draft preview</span>} + {nowDate && <time datetime={nowDate.toISOString()}>{formatKnowledgeDate(nowDate)}</time>} + </div> + </header> + {nowHtml ? ( + <div class="blog-content knowledge-now-body">{raw(nowHtml)}</div> + ) : ( + <p class="knowledge-empty"> + The first public NOW note has not been published yet. + </p> + )} + </section> + + <section class="knowledge-entries" id="entries" aria-labelledby="knowledge-entries-title"> + <div class="knowledge-section-heading"> + <div> + <h2 id="knowledge-entries-title">Entries</h2> + </div> + <p>{entries.length} entries across {topics.size} topics</p> + </div> + + <section class="knowledge-controls" aria-label="Knowledge filters"> + <label> + <span>Find a topic</span> + <input + type="search" + id="knowledge-search" + placeholder="Search titles, summaries, and topics" + autocomplete="off" + /> + </label> + <p id="knowledge-count" aria-live="polite">{entries.length} entries</p> + </section> + + {entries.length === 0 ? ( + <p class="knowledge-empty"> + Entries appear only after explicit public review. + </p> + ) : ( + <div class="knowledge-list" id="knowledge-list"> + {entries.map((entry) => ( + <article + class="knowledge-row" + data-knowledge-entry + data-search={`${entry.title} ${entry.summary} ${entry.kind} ${entry.topics.join(" ")}`.toLowerCase()} + key={entry.slug} + > + <div class="knowledge-row-meta"> + <span>{kindLabel(entry.kind)}</span> + <span>{entry.claimMode}</span> + </div> + <div class="knowledge-row-copy"> + <h3><a href={`/knowledge/${entry.slug}`}>{entry.title}</a></h3> + <p>{entry.summary}</p> + <ul aria-label="Topics"> + {entry.topics.map((topic) => <li>{topic}</li>)} + </ul> + </div> + <div class="knowledge-row-state"> + {entry.draft && <span class="knowledge-draft">Draft preview</span>} + <span>{entry.confidence} confidence</span> + </div> + </article> + ))} + </div> + )} + </section> + + {await Library({ embedded: true })} + + <footer class="knowledge-footer"> + <p> + NOW, entries, links, and annotations are public records. The private + graph they came from stays private. + </p> + </footer> + <script src="/public/knowledge.js"></script> + </div> + ); +} diff --git a/src/components/annotations.tsx b/src/components/library-annotations.tsx similarity index 65% rename from src/components/annotations.tsx rename to src/components/library-annotations.tsx index e2d44ad..950c879 100644 --- a/src/components/annotations.tsx +++ b/src/components/library-annotations.tsx @@ -1,9 +1,6 @@ -// Annotations page — renders at.margin.annotation records from Cameron's PDS. +// Annotation records and rendering for the unified library page. -import { getProfile } from "../data.ts"; -import { PageNav } from "./page-nav.tsx"; - -interface Annotation { +export interface Annotation { uri: string; rkey: string; target: { source: string; selector?: { exact?: string } }; @@ -11,7 +8,7 @@ interface Annotation { createdAt?: string; } -async function listAnnotations(): Promise<Annotation[]> { +export async function listAnnotations(): Promise<Annotation[]> { const did = process.env.CAMERON_DID || "did:plc:gfrmhdmjvxn2sjedzboeudef"; const pds = "https://bsky.social"; let cursor: string | undefined; @@ -49,14 +46,6 @@ async function listAnnotations(): Promise<Annotation[]> { ); } -function escapeHtml(str: string): string { - return str - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """); -} - function displayUrl(url: string): string { try { const u = new URL(url); @@ -66,39 +55,10 @@ function displayUrl(url: string): string { } } -export async function Annotations() { - const [annotations, profile] = await Promise.all([ - listAnnotations(), - getProfile(), - ]); - +export function AnnotationList({ annotations }: { annotations: Annotation[] }) { return ( - <div> - <PageNav profile={profile} active="annotations" /> - - <h1 class="page-title">Annotations</h1> - <p class="page-subtitle"> - Web annotations via{" "} - <a - href="https://margin.at" - target="_blank" - rel="noopener" - > - Margin - </a> - , stored as{" "} - <a - href="https://margin.at/lexicon" - target="_blank" - rel="noopener" - > - at.margin.annotation - </a>{" "} - records on my PDS. - </p> - - <div class="annotations-list"> - {annotations.map((a) => { + <div class="annotations-list"> + {annotations.map((a) => { const date = a.createdAt ? new Date(a.createdAt).toLocaleDateString("en-US", { month: "short", @@ -116,16 +76,16 @@ export async function Annotations() { rel="noopener" style="color:var(--site-accent);text-decoration:none;font-size:0.8125rem" > - {escapeHtml(displayUrl(a.target.source))} + {displayUrl(a.target.source)} </a> </div> {a.target.selector?.exact && ( <blockquote class="annotation-quote"> - {escapeHtml(a.target.selector.exact)} + {a.target.selector.exact} </blockquote> )} {a.body?.value && ( - <p class="annotation-body">{escapeHtml(a.body.value)}</p> + <p class="annotation-body">{a.body.value}</p> )} <div class="annotation-meta"> {date && <span>{date}</span>} @@ -140,8 +100,7 @@ export async function Annotations() { </div> </div> ); - })} - </div> + })} </div> ); } diff --git a/src/components/library.tsx b/src/components/library.tsx new file mode 100644 index 0000000..2581266 --- /dev/null +++ b/src/components/library.tsx @@ -0,0 +1,276 @@ +// Library page — combines saved links and annotations from Cameron's PDS. + +import { + getProfile, + getSembleData, + type SembleCard, +} from "../data.ts"; +import { AnnotationList, listAnnotations } from "./library-annotations.tsx"; +import { PageNav } from "./page-nav.tsx"; + +function displayDomain(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +function cardTitle(card: SembleCard): string { + const metadataTitle = card.cardContent?.title; + if (metadataTitle && metadataTitle !== "Bluesky") return metadataTitle; + + try { + const url = new URL(card.url); + if (url.hostname === "bsky.app" && url.pathname.includes("/profile/")) { + const profile = url.pathname.split("/").filter(Boolean).at(-1); + if (profile) return `@${profile}`; + } + } catch { + // Fall through to the stored title or domain. + } + + return metadataTitle || displayDomain(card.url); +} + +function formattedDate(date: string): string { + return new Date(date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function recordUrl(uri?: string): string | undefined { + return uri ? `https://pdsls.dev/${uri}` : undefined; +} + +function cleanDescription(description?: string): string | undefined { + return description?.replace(/\s+/g, " ").trim(); +} + +export async function Library({ embedded = false }: { embedded?: boolean } = {}) { + const [{ collections, cards, available }, annotations, profile] = await Promise.all([ + getSembleData(100), + listAnnotations(), + getProfile(), + ]); + + const urlCards = cards.filter((card) => card.type === "URL"); + const looseCount = urlCards.filter((card) => card.collections.length === 0).length; + + return ( + <div class="semble-shell"> + {!embedded && <PageNav profile={profile} active="knowledge" />} + + <main class="semble-page" id={embedded ? "links" : undefined}> + {!embedded && <header class="semble-hero"> + <div class="semble-hero-copy"> + <h1>Library</h1> + <p class="semble-intro"> + Links and passages I wanted to keep. Collections gather related + material; annotations stay attached to the thing that provoked them. + </p> + </div> + + <dl class="semble-totals" aria-label="Catalogue totals"> + <div> + <dt><a href="#saved-links">Saved links</a></dt> + <dd>{String(urlCards.length).padStart(2, "0")}</dd> + </div> + <div> + <dt><a href="#saved-links">Collections</a></dt> + <dd>{String(collections.length).padStart(2, "0")}</dd> + </div> + <div> + <dt><a href="#annotations">Annotations</a></dt> + <dd>{String(annotations.length).padStart(2, "0")}</dd> + </div> + </dl> + </header>} + + {!available ? ( + <section class="semble-unavailable" aria-live="polite"> + <p class="semble-kicker">Catalogue unavailable</p> + <h2>The records are still there.</h2> + <p> + This page could not reach the PDS just now. It is an upstream + read failure, not an empty collection. + </p> + </section> + ) : ( + <section + class="semble-section semble-cards-section" + id="saved-links" + aria-labelledby="semble-cards-title" + > + <div class="semble-section-heading"> + <div> + <h2 id="semble-cards-title">Saved links</h2> + </div> + <p id="semble-filter-status" aria-live="polite"> + {urlCards.length} links saved with{" "} + <a + href="https://semble.so/profile/cameron.stream" + target="_blank" + rel="noopener" + > + Semble + </a> + . + </p> + </div> + + <div class="semble-filter-bar" aria-label="Filter cards"> + <button + type="button" + data-collection-filter="all" + aria-pressed="true" + > + All <span>{urlCards.length}</span> + </button> + {collections.map((collection) => ( + <button + type="button" + data-collection-filter={collection.id} + aria-pressed="false" + key={collection.id} + > + {collection.name} <span>{collection.cardCount}</span> + </button> + ))} + {looseCount > 0 && ( + <button + type="button" + data-collection-filter="loose" + aria-pressed="false" + > + Loose <span>{looseCount}</span> + </button> + )} + </div> + + {urlCards.length === 0 ? ( + <p class="semble-empty">No cards yet.</p> + ) : ( + <div class="semble-card-grid" id="semble-card-grid"> + {urlCards.map((card) => { + const title = cardTitle(card); + const domain = displayDomain(card.url); + const description = cleanDescription(card.cardContent?.description); + const cardCollections = card.collections.map((item) => item.id); + const pdsRecordUrl = recordUrl(card.uri); + + return ( + <article + class="semble-card" + key={card.id} + data-card-collections={cardCollections.join(" ")} + data-card-loose={cardCollections.length === 0 ? "true" : "false"} + > + <div class="semble-card-body"> + <div class="semble-card-source"> + <span>{card.cardContent?.siteName || domain}</span> + <span>{formattedDate(card.createdAt)}</span> + </div> + <h3> + <a href={card.url} target="_blank" rel="noopener"> + {title} + <span class="semble-external" aria-hidden="true">↗</span> + </a> + </h3> + {description && <p class="semble-card-description">{description}</p>} + + {card.note?.text && ( + <aside class="semble-card-note"> + <span>Note</span> + <p>{card.note.text}</p> + </aside> + )} + + <div class="semble-card-footer"> + <div class="semble-card-membership"> + {card.collections.length > 0 ? ( + card.collections.map((collection) => ( + <button + type="button" + data-collection-filter={collection.id} + aria-pressed="false" + key={collection.id} + > + {collection.name} + </button> + )) + ) : ( + <span>Loose</span> + )} + </div> + {pdsRecordUrl && ( + <a + href={pdsRecordUrl} + target="_blank" + rel="noopener" + class="semble-record-link" + > + record + </a> + )} + </div> + </div> + </article> + ); + })} + </div> + )} + </section> + )} + + <section + class="semble-section library-annotations-section" + id="annotations" + aria-labelledby="library-annotations-title" + > + <div class="semble-section-heading"> + <div> + <h2 id="library-annotations-title">Annotations</h2> + </div> + <p> + {annotations.length} passages and notes made with{" "} + <a href="https://margin.at" target="_blank" rel="noopener"> + Margin + </a> + . + </p> + </div> + {annotations.length === 0 ? ( + <p class="semble-empty">No annotations yet.</p> + ) : ( + <AnnotationList annotations={annotations} /> + )} + </section> + + <footer class="semble-footer"> + <p>Public, portable records on my PDS.</p> + <div> + <a + href="https://semble.so/profile/cameron.stream" + target="_blank" + rel="noopener" + > + Semble profile <span aria-hidden="true">↗</span> + </a> + <a + href="https://margin.at" + target="_blank" + rel="noopener" + > + Margin <span aria-hidden="true">↗</span> + </a> + </div> + </footer> + </main> + + <script src="/public/semble.js"></script> + </div> + ); +} diff --git a/src/components/lora-guide.tsx b/src/components/lora-guide.tsx new file mode 100644 index 0000000..05a2a53 --- /dev/null +++ b/src/components/lora-guide.tsx @@ -0,0 +1,466 @@ +const findings = [ + { + setting: "Small and medium sets of desired answers", + observed: "A sufficiently large LoRA learned at about the same speed and reached about the same final prediction error, called loss, as full fine-tuning.", + meaning: "This is the paper’s low-regret regime.", + }, + { + setting: "Larger sets of desired answers", + observed: "Small adapters kept up at first, then improved more slowly as training continued. More rank moved that slowdown later.", + meaning: "The adapter eventually became too small for the job.", + }, + { + setting: "Large training batches", + observed: "LoRA fell further behind as more examples were combined into each update. Increasing rank did not close the gap.", + meaning: "Some failures come from how the updates are optimized, not adapter size.", + }, + { + setting: "Math training from answer scores", + observed: "Using policy-gradient reinforcement learning, even rank-1 adapters reached roughly the same best reward as full fine-tuning on the tested math tasks.", + meaning: "These RL runs needed very little adapter capacity.", + }, +]; + +const experimentSteps = [ + { + title: "Hold everything else constant", + text: "Use the same base model, training data, held-out data, evaluation set, and training budget for every run.", + }, + { + title: "Create a useful baseline", + text: "Use full fine-tuning if it is feasible. Otherwise use the strongest less-constrained method you can actually run.", + }, + { + title: "Test where LoRA is attached", + text: "Compare attention-only LoRA with LoRA on all major weight matrices. Equal parameter counts do not make these equivalent.", + }, + { + title: "Vary rank and learning rate together", + text: "Try several adapter sizes and several learning rates for each size. One failed run cannot tell you whether the adapter was too small or merely tuned badly.", + }, + { + title: "Read the whole learning curve", + text: "Graph performance throughout training and look for the point where LoRA begins improving more slowly. A final score alone can hide when the methods started to diverge.", + }, + { + title: "Evaluate the behavior you care about", + text: "For agent corrections, score judgment, tool use, style, and unrelated regressions separately. One average score will hide the useful failure mode.", + }, +]; + +function MatrixGrid({ className, label }: { className: string; label: string }) { + return <div class={`lora-matrix ${className}`} aria-hidden="true"><span>{label}</span></div>; +} + +export function LoraGuide() { + return ( + <main class="lora-page"> + <nav class="lora-nav" aria-label="LoRA guide navigation"> + <a href="/artifacts">← ARTIFACTS</a> + <div> + <span>FIELD GUIDE / 002</span> + <a href="#start">PRACTICAL START</a> + </div> + </nav> + + <header class="lora-hero"> + <div class="lora-hero-copy"> + <p class="lora-kicker">THINKING MACHINES LAB / SEPTEMBER 2025</p> + <h1>LORA<br /><span>WITHOUT REGRET</span></h1> + <p class="lora-deck"> + Fine-tuning teaches an existing model a narrower job. Full fine-tuning + changes every weight in the model. LoRA leaves those weights alone and + learns a much smaller correction. Thinking Machines asked whether that + smaller correction can learn just as well. + </p> + </div> + + <figure class="lora-factorization"> + <div class="lora-factor-row" role="img" aria-label="A frozen weight matrix plus a smaller learned correction"> + <MatrixGrid className="lora-matrix-base" label="W" /> + <span class="lora-operator">+</span> + <div class="lora-adapter-product"> + <MatrixGrid className="lora-matrix-b" label="B" /> + <MatrixGrid className="lora-matrix-a" label="A" /> + </div> + </div> + <figcaption> + <span>KEEP</span> the original model weights frozen + <span>LEARN</span> two thin matrices that form a correction + </figcaption> + </figure> + + <div class="lora-thesis"> + <span>SHORT ANSWER</span> + <p> + Often, yes. In these experiments, LoRA matched full fine-tuning when + the adapter was large enough, attached throughout the model, and tuned + for LoRA rather than treated like a smaller version of full fine-tuning. + </p> + </div> + + <ol class="lora-topline"> + <li><span>01</span><p>For the small and medium datasets tested, well-configured LoRA often matched full fine-tuning.</p></li> + <li><span>02</span><p>It worked best when the correction was added throughout the model, rather than only to its attention system.</p></li> + <li><span>03</span><p>It could fall behind when the correction was too small or too many examples were combined into one training step.</p></li> + <li><span>04</span><p>In these tests, LoRA needed training steps about 10× larger than full fine-tuning did.</p></li> + </ol> + </header> + + <section class="lora-section" aria-labelledby="primer-title"> + <div class="lora-section-label"><span>01</span><span id="primer-title">WHAT FINE-TUNING CHANGES</span></div> + <div class="lora-section-head lora-primer-head"> + <h2>Start with the model that already exists.</h2> + <p> + A pretrained language model has already learned broad patterns from a + huge amount of text. Post-training gives that base model a smaller, + more specific set of examples so its behavior fits a task, domain, or + preference more closely. + </p> + </div> + + <div class="lora-primer-grid"> + <article> + <span>WEIGHT</span> + <strong>One learned number inside the model.</strong> + <p>Billions of weights work together to turn an input into an output. They are usually stored in large rectangular tables called matrices.</p> + </article> + <article> + <span>FULL FINE-TUNING</span> + <strong>Allow every weight to change.</strong> + <p>This gives training maximum freedom. It also stores a gradient, the direction each weight should move, and optimizer state, the running statistics used to choose the next update, for the entire model.</p> + </article> + <article> + <span>LORA</span> + <strong>Freeze the base model and train an adapter.</strong> + <p>The adapter stores a smaller set of changes. The original model remains intact, and different adapters can share it.</p> + </article> + </div> + + <aside class="lora-question"> + <span>THE PAPER’S QUESTION</span> + <p>If LoRA changes far fewer numbers, when does it learn as quickly and perform as well as changing the whole model?</p> + </aside> + </section> + + <section class="lora-section" aria-labelledby="mechanism-title"> + <div class="lora-section-label"><span>02</span><span id="mechanism-title">LORA’S TRICK</span></div> + <div class="lora-two-column"> + <div> + <h2>Train a correction, not a replacement.</h2> + <p> + Inside the model, weights are grouped into matrices. Full fine-tuning + edits a matrix <em>W</em> directly. LoRA freezes <em>W</em> and adds a + correction made by multiplying two much thinner matrices, <em>B</em> + and <em>A</em>. + </p> + <p> + The width of those thin matrices is called the <strong>rank</strong>. + A higher rank lets the correction represent more independent patterns + of change, but it also trains more parameters. + </p> + </div> + <div class="lora-equation-card"> + <p class="lora-equation">W′ = W + <span>α/r</span> BA</p> + <dl> + <div><dt>W</dt><dd>the original matrix; kept frozen</dd></div> + <div><dt>BA</dt><dd>the learned correction</dd></div> + <div><dt>r</dt><dd>rank; the correction’s width and capacity</dd></div> + <div><dt>α/r</dt><dd>a scale applied to the correction</dd></div> + </dl> + </div> + </div> + + <div class="lora-rank-lab" data-rank-lab> + <div class="lora-rank-head"> + <div> + <p class="lora-mini-label">TRY IT / ONE 4096 × 4096 WEIGHT MATRIX</p> + <h3>Rank controls the size of the correction.</h3> + </div> + <output for="lora-rank" aria-live="polite"><span data-rank-value>32</span><small>RANK</small></output> + </div> + <label class="lora-slider-label" for="lora-rank"> + Adapter rank + <input id="lora-rank" type="range" min="1" max="512" value="32" step="1" /> + </label> + <div class="lora-rank-visual" aria-hidden="true"> + <div class="lora-rank-base"><span>16,777,216</span><small>NUMBERS IN THE FULL MATRIX</small></div> + <div class="lora-rank-adapter"></div> + </div> + <div class="lora-rank-readout"> + <p><strong data-adapter-params>262,144</strong><span>numbers trained by the two thin matrices</span></p> + <p><strong data-percent>1.56%</strong><span>of the full matrix’s parameter count</span></p> + </div> + <p class="lora-caption"> + This example shows one square matrix. A whole model contains many + matrices, and the total adapter size depends on which of them receive + LoRA. The next question is whether the smaller correction is large + enough for what training is trying to teach. + </p> + </div> + </section> + + <section class="lora-section lora-dark" aria-labelledby="evidence-title"> + <div class="lora-section-label lora-section-label-dark"><span>03</span><span id="evidence-title">WHAT THE EXPERIMENTS FOUND</span></div> + <div class="lora-capacity-intro"> + <h2>LoRA works inside a boundary, not by default.</h2> + <p> + Thinking Machines compared LoRA with full fine-tuning across several + Llama and Qwen models. They changed the adapter size, the size of each + training step (the learning rate), the number of examples combined into + each step (the batch size), the dataset size, and the parts of the model + that received adapters. The result was a map of where LoRA kept up and + where it did not. + </p> + </div> + + <div class="lora-findings-grid"> + {findings.map((finding, index) => ( + <article> + <span>{String(index + 1).padStart(2, "0")}</span> + <h3>{finding.setting}</h3> + <p>{finding.observed}</p> + <strong>{finding.meaning}</strong> + </article> + ))} + </div> + + <details class="lora-technical-note lora-technical-note-dark"> + <summary>Evidence base</summary> + <p> + The supervised experiments used Tulu3 and OpenThoughts3 for instruction + following and reasoning. The reinforcement-learning experiments used + math problems with answer correctness as the reward. Ranks ranged from + 1 to 512, and the researchers searched for a good learning rate in every + condition so that a bad default would not decide the comparison. + </p> + </details> + </section> + + <section class="lora-section" aria-labelledby="capacity-title"> + <div class="lora-section-label"><span>04</span><span id="capacity-title">WHY CAPACITY CHANGES BY TASK</span></div> + <div class="lora-section-head"> + <h2>The adapter only needs to hold what the new training adds.</h2> + <p> + It does not need to relearn everything in the base model. The useful + comparison is between the size of the adapter and the amount of new + information supplied by training. Different training methods can supply + very different amounts. + </p> + </div> + <div class="lora-signal-grid lora-signal-grid-light"> + <article> + <div class="lora-bandwidth lora-bandwidth-dense" aria-hidden="true"> + {Array.from({ length: 18 }, (_, index) => <i style={`--i:${index}`}></i>)} + </div> + <p class="lora-mini-label">SUPERVISED FINE-TUNING / SFT</p> + <h3>A correction at every target token</h3> + <p> + The model is shown a desired answer. Every token in that answer tells + training what the model should have predicted, so one example can + contain hundreds or thousands of small corrections. + </p> + </article> + <article> + <div class="lora-bandwidth lora-bandwidth-sparse" aria-hidden="true"> + <i></i><i></i><i></i> + </div> + <p class="lora-mini-label">POLICY-GRADIENT RL</p> + <h3>One score for a completed answer</h3> + <p> + The model produces an answer, receives a score, and training makes + rewarded behavior more likely. In these math experiments, a whole + answer was largely reduced to one comparative score. + </p> + </article> + </div> + <aside class="lora-question lora-capacity-answer"> + <span>WHY RANK 1 COULD WORK</span> + <p> + The tested RL runs supplied far less new information per answer than + supervised training did. This gives one plausible reason even a tiny + adapter was sufficient in those experiments. The explanation applies to + the policy-gradient setup in the post, not to every method called + reinforcement learning. + </p> + </aside> + </section> + + <section class="lora-section" aria-labelledby="setup-title"> + <div class="lora-section-label"><span>05</span><span id="setup-title">A FAIR LORA SETUP</span></div> + <div class="lora-placement-grid"> + <div class="lora-block-map" role="img" aria-label="Two major parts of a transformer block: attention and a feed-forward MLP or expert network"> + <div class="lora-block-attn"><span>ATTENTION</span><small>combines information across tokens</small></div> + <div class="lora-block-mlp"><span>MLP / EXPERTS</span><small>transforms each token representation</small></div> + </div> + <div> + <p class="lora-mini-label">CHOICE 01 / WHERE THE ADAPTER GOES</p> + <h2>Attach LoRA to all the major weight matrices.</h2> + <p> + A transformer has attention matrices and larger feed-forward networks + called multilayer perceptrons, or MLPs. Mixture-of-experts models spread + those feed-forward transformations across specialized experts. + </p> + <p> + Many older recipes put LoRA on attention only. In these experiments, + MLP-only LoRA worked better than attention-only LoRA. Applying it to + all major matrices was the safest default. Attention-only still lost + when its rank was increased enough to give it nearly the same number + of trainable parameters. + </p> + </div> + </div> + + <div class="lora-setting-grid"> + <article> + <span>CHOICE 02</span> + <h3>Rank</h3> + <p><strong>What it controls:</strong> how much change the adapter can represent.</p> + <p><strong>What the post found:</strong> too little rank made supervised learning slow down sooner as the dataset grew.</p> + </article> + <article> + <span>CHOICE 03</span> + <h3>Learning rate</h3> + <p><strong>What it controls:</strong> the size of each training update.</p> + <p><strong>What the post found:</strong> across 14 sweeps, the best LoRA rate was about 9.8× the best FullFT rate. Use 10× as a point to test, not a law.</p> + </article> + <article> + <span>CHOICE 04</span> + <h3>Batch size</h3> + <p><strong>What it controls:</strong> how many examples are combined into one update.</p> + <p><strong>What the post found:</strong> LoRA was less tolerant of large batches in one setting, and more rank did not fix the gap.</p> + </article> + </div> + + <details class="lora-technical-note"> + <summary>Why LoRA needs different tuning</summary> + <p> + LoRA represents one correction as the product of two matrices. That + changes how optimization behaves. The standard formula divides the + correction by rank, which makes the earliest updates similar across + ranks. One of the two matrices also begins at zero, so the effective + size of later updates changes as training progresses. These details help + explain why copying FullFT settings is not a fair test. + </p> + </details> + </section> + + <section class="lora-section lora-dark" aria-labelledby="savings-title"> + <div class="lora-section-label lora-section-label-dark"><span>06</span><span id="savings-title">WHAT LORA ACTUALLY SAVES</span></div> + <div class="lora-capacity-intro"> + <h2>The biggest practical saving is memory.</h2> + <p> + The frozen base model still participates in training. LoRA avoids + storing gradients and optimizer state for all of its weights. Only the + much smaller adapter needs that training state. + </p> + </div> + <div class="lora-benefit-grid"> + <article><span>01</span><h3>Less training memory</h3><p>Only the adapter needs gradients and optimizer state, so LoRA can often train with far less memory than FullFT.</p></article> + <article><span>02</span><h3>Small portable variants</h3><p>Many adapters can share one base model and be transferred or loaded independently.</p></article> + <article><span>03</span><h3>Some compute savings</h3><p>For the low-rank matrix operations analyzed in the post, LoRA used slightly more than two-thirds of FullFT’s arithmetic.</p></article> + </div> + <p class="lora-compute-statement"> + LoRA is dramatically smaller in trainable state. Its arithmetic cost is + lower by roughly <strong>one third</strong> in this matrix-level estimate, + not by the same huge factor as its parameter count. + </p> + <details class="lora-technical-note lora-technical-note-dark"> + <summary>Where the two-thirds estimate comes from</summary> + <p> + For a square N × N matrix, FullFT spends about 3N² multiply-adds on the + forward pass, the backward pass into the input, and the weight gradient. + LoRA still pays 2N² to run through the frozen matrix, then adds about 6Nr + for its two thin matrices. When rank r is much smaller than N, that is + just above two-thirds of FullFT’s matrix-operation cost. The estimate + excludes attention operations, which can matter at long context. + </p> + </details> + </section> + + <section class="lora-section lora-run" id="start" aria-labelledby="start-title"> + <div class="lora-section-label"><span>07</span><span id="start-title">A PRACTICAL START</span></div> + <div class="lora-run-head"> + <h2>Treat LoRA as a method to test, not a switch to flip.</h2> + <p> + The paper does not provide a formula for the right rank on every + dataset. It provides a better first experiment and a way to diagnose why + LoRA falls behind. + </p> + </div> + + <div class="lora-decision-columns"> + <article> + <span>START WITH LORA WHEN</span> + <ul> + <li>The dataset is small or medium enough for the adapter capacity you can afford.</li> + <li>You are running policy-gradient RL similar to the tested math setup.</li> + <li>Training memory or portable model variants matter.</li> + <li>You can attach adapters broadly and test more than one setting.</li> + </ul> + </article> + <article> + <span>CONSIDER MORE RANK OR FULLFT WHEN</span> + <ul> + <li>The work resembles training on a very large new text corpus.</li> + <li>LoRA learns more slowly even after a fair rank and learning-rate search.</li> + <li>Large batches are required and produce a persistent gap.</li> + <li>A small, swappable adapter offers no operational benefit.</li> + </ul> + </article> + </div> + + <ol class="lora-run-steps"> + {experimentSteps.map((step, index) => ( + <li> + <span>{String(index + 1).padStart(2, "0")}</span> + <div><strong>{step.title}</strong><p>{step.text}</p></div> + </li> + ))} + </ol> + + <aside class="lora-application-note"> + <span>ONE USEFUL AGENT-TRAINING TEST</span> + <p> + Suppose the dataset contains agent trajectories that a person chose, + rejected, or corrected. Compare several ranks and learning rates against + the same baseline. Then measure whether the model learned the underlying + judgment, rather than only copying the wording of the correction. This is + a proposed experiment derived from the post, not a result the authors reported. + </p> + </aside> + </section> + + <section class="lora-section lora-limits" aria-labelledby="limits-title"> + <div class="lora-section-label"><span>08</span><span id="limits-title">WHAT THE POST DOES NOT SETTLE</span></div> + <p class="lora-limits-intro"> + These experiments are strong evidence about the tested models, datasets, + and policy-gradient setups. They do not prove that every LoRA + configuration will match full fine-tuning. + </p> + <div class="lora-limits-grid"> + <article><span>01</span><h3>No rank formula</h3><p>There is still no reliable way to predict how much rank a real dataset needs before running the experiment.</p></article> + <article><span>02</span><h3>No 10× theory</h3><p>The learning-rate ratio was consistent in these tests, but the authors do not yet have a theory that makes it universal.</p></article> + <article><span>03</span><h3>A narrow RL result</h3><p>Rank 1 worked in the tested policy-gradient math setup. Other forms of RL can provide much richer training signals.</p></article> + <article><span>04</span><h3>Benchmarks are cleaner than products</h3><p>Loss and math reward are easy to measure. Persistent agent behavior still needs its own behavioral evaluation.</p></article> + </div> + </section> + + <footer class="lora-footer"> + <div> + <span>PRIMARY SOURCES</span> + <a href="https://thinkingmachines.ai/blog/lora/" target="_blank" rel="noopener" aria-label="LoRA Without Regret, opens in a new tab">LORA WITHOUT REGRET ↗</a> + <a href="https://arxiv.org/abs/2106.09685" target="_blank" rel="noopener" aria-label="Original LoRA paper, opens in a new tab">ORIGINAL LORA PAPER ↗</a> + <a href="https://arxiv.org/abs/2405.09673" target="_blank" rel="noopener" aria-label="LoRA Learns Less and Forgets Less, opens in a new tab">LORA LEARNS LESS AND FORGETS LESS ↗</a> + <a href="https://arxiv.org/abs/2310.18547" target="_blank" rel="noopener" aria-label="Punica: Multi-Tenant LoRA Serving, opens in a new tab">PUNICA: MULTI-TENANT SERVING ↗</a> + </div> + <div class="lora-footer-note"> + <strong>THE UPDATE IS SMALL.</strong> + <span>THE EXPERIMENT STILL HAS TO BE GOOD.</span> + </div> + </footer> + + <script src="/public/lora-artifact.js"></script> + </main> + ); +} diff --git a/src/components/lora-topology.tsx b/src/components/lora-topology.tsx new file mode 100644 index 0000000..136b05f --- /dev/null +++ b/src/components/lora-topology.tsx @@ -0,0 +1,390 @@ +import { getProfile } from "../data.ts"; +import { PageNav } from "./page-nav.tsx"; + +const topologyCells = Array.from({ length: 96 }, (_, index) => ({ + index, + cluster: Math.floor(index / 12), +})); + +const contentExamples = [ + { + type: "One repeated rule", + pressure: "Lower expected pressure", + example: "Always state uncertainty, return one schema, or follow one tool protocol.", + reason: "Many examples can reinforce nearly the same change.", + }, + { + type: "A narrow style or format", + pressure: "Usually lower", + example: "Rewrite in one house style, produce valid SQL, or emit a fixed JSON shape.", + reason: "The base model may already have the skill; training only has to steer it consistently.", + }, + { + type: "Several distinct systems", + pressure: "Middle", + example: "Thirty-two institutions, each with its own compact policy.", + reason: "Examples share a solution inside each group, but not across every group.", + }, + { + type: "Many arbitrary facts", + pressure: "Higher expected pressure", + example: "Thousands of unrelated entity mappings, identifiers, or exceptions.", + reason: "Each correction may need its own solution instead of reusing one rule.", + }, + { + type: "Broad domain injection", + pressure: "Potentially high", + example: "Continued training on legal text, a new language, or a large code corpus.", + reason: "Training may need to change many token relationships and features at once.", + }, +]; + +const experimentSteps = [ + { + title: "Build 1,024 synthetic worlds.", + text: "Each world has an identifier and a substitution cipher mapping 32 input tokens to 32 output tokens. Training shows every mapping; evaluation recombines known tokens into sequences the model has not seen.", + }, + { + title: "Change only the sharing structure.", + text: "Keep the examples, sequence lengths, token frequencies, and total token count matched. Only the number of independent ciphers changes: one, thirty-two, or 1,024.", + }, + { + title: "Compare the same update channels.", + text: "Train dense updates to a fixed set of model matrices, then LoRA on those same matrices at ranks 1, 4, and 16. Sweep learning rates for every method so one bad default cannot decide the result.", + }, + { + title: "Measure the frontier.", + text: "Find the smallest LoRA rank that matches the dense update on held-out sequences. Then inspect the effective rank of the dense weight changes themselves.", + }, +]; + +export async function LoraTopology() { + const profile = await getProfile(); + + return ( + <div class="lora-article-shell"> + <PageNav profile={profile} active="other" /> + + <article class="lora-article"> + <header class="lora-article-header"> + <a class="lora-backlink" href="/artifacts">Artifacts / Experiment 003</a> + <h1>What fits in a LoRA?</h1> + <p class="lora-deck"> + A testable idea about why a million examples can be easy to learn while a + much smaller dataset can exhaust a low-rank adapter. + </p> + <div class="lora-meta" aria-label="Article status"> + <span>Hypothesis</span> + <span>Proposed experiment</span> + <span>July 2026</span> + </div> + </header> + + <div class="lora-prose"> + <section aria-labelledby="short-answer-title"> + <h2 id="short-answer-title">The short answer</h2> + <p class="lora-lede"> + LoRA may care less about how many examples are in a dataset than about + how many <strong>different kinds of change</strong> those examples demand. + </p> + <p> + If a million examples all reinforce one rule, they can share a solution. + If ten thousand examples encode ten thousand unrelated exceptions, they + may pull the model in many different directions. The first dataset is + large but geometrically coherent. The second is smaller but fragmented. + </p> + <p> + That distinction suggests a more useful question than “How large is the + dataset?” Ask: <strong>How much can its examples reuse the same update?</strong> + </p> + + <figure class="lora-direction-figure"> + <div class="lora-direction-group" aria-hidden="true"> + <span style="--offset:-2"></span> + <span style="--offset:-1"></span> + <span style="--offset:0"></span> + <span style="--offset:1"></span> + <span style="--offset:2"></span> + </div> + <div class="lora-rank-gate" aria-hidden="true">RANK</div> + <div class="lora-direction-group lora-direction-group-many" aria-hidden="true"> + {Array.from({ length: 11 }, (_, index) => <span style={`--offset:${index - 5}`}></span>)} + </div> + <figcaption> + LoRA gives training a narrower channel for changing the model. Coherent + updates can pass through together; unrelated updates may need a wider channel. + </figcaption> + </figure> + </section> + + <section aria-labelledby="lora-basics-title"> + <h2 id="lora-basics-title">First, what LoRA changes</h2> + <p> + A language model is a large collection of learned numbers called + <strong> weights</strong>. Those weights determine how strongly one pattern + of tokens leads to another. Fine-tuning shows the model new examples and + adjusts the weights so the desired outputs become more likely. + </p> + <p> + Full fine-tuning allows every selected weight to move directly. LoRA, + short for <strong>low-rank adaptation</strong>, freezes the base model and + learns a smaller correction beside it. At inference time, the correction + is added to the original weights. + </p> + + <dl class="lora-definitions"> + <div> + <dt>Base model</dt> + <dd>The model before this new training. Fine-tuning adjusts an existing system rather than starting over.</dd> + </div> + <div> + <dt>Adapter</dt> + <dd>The learned correction. It can be saved, swapped, and served separately from the frozen base.</dd> + </div> + <div> + <dt>Rank</dt> + <dd>The width of that correction. Higher rank creates more trainable parameters and permits a more complex weight update.</dd> + </div> + <div> + <dt>Update direction</dt> + <dd>A useful intuition for one pattern in how many weights need to move together. It is not a literal human-readable rule inside the network.</dd> + </div> + </dl> + + <aside class="lora-note"> + <strong>A working intuition</strong> + <p> + Every training example votes for changes to the model. When many votes + point roughly the same way, a narrow update channel may be enough. When + they point in unrelated directions, the adapter may need more rank. + </p> + </aside> + </section> + + <section aria-labelledby="content-shape-title"> + <h2 id="content-shape-title">Content has a shape</h2> + <p> + Imagine 1,024 fictional worlds. Each world follows a token-substitution + rule. We keep the number of examples and tokens exactly the same, then + change only how widely the rule is shared. + </p> + <p> + In the coherent version, every world uses one cipher. In the clustered + version, groups of worlds share 32 ciphers. In the fragmented version, + every world has its own cipher. Use the figure to compare the three. + </p> + + <figure class="topology-instrument" data-topology-instrument data-mode="coherent"> + <div class="topology-controls" role="group" aria-label="Choose a content topology"> + <button type="button" class="is-active" data-mode-button="coherent" aria-pressed="true"><span>1</span>Coherent</button> + <button type="button" data-mode-button="clustered" aria-pressed="false"><span>32</span>Clustered</button> + <button type="button" data-mode-button="fragmented" aria-pressed="false"><span>1,024</span>Fragmented</button> + </div> + + <div class="topology-world-field" role="img" aria-label="Representative sample of worlds that all share one rule"> + {topologyCells.map((cell) => ( + <i style={`--cell:${cell.index};--cluster:${cell.cluster}`}></i> + ))} + </div> + + <div class="topology-readout" aria-live="polite"> + <p><span>Independent maps</span><strong data-map-count>1</strong></p> + <p><span>Sharing</span><strong data-sharing>Across all worlds</strong></p> + <p><span>Expected rank frontier</span><strong data-frontier>Lowest</strong></p> + </div> + + <p class="topology-summary" data-topology-summary> + Every world uses the same substitution map. Learning in one world helps + in every other world, so all examples can reinforce one solution. + </p> + + <div class="topology-frontier" aria-hidden="true"> + <span>lower rank</span><i><b></b></i><span>higher rank</span> + </div> + + <figcaption> + This visual states the prediction, not a measured result. The proposed + ordering is coherent < clustered < fragmented. The experiment below + exists to find out whether that ordering is real. + </figcaption> + </figure> + </section> + + <section aria-labelledby="predictions-title"> + <h2 id="predictions-title">What the hypothesis predicts</h2> + <p> + Topic count is not the same as update complexity. A dataset can discuss + many subjects while repeatedly teaching the same behavior. The hostile + case is many corrections that cannot reuse the same solution. + </p> + + <div class="lora-prediction-list" aria-label="Expected LoRA pressure by content type"> + {contentExamples.map((item, index) => ( + <article> + <span class="lora-prediction-number">{String(index + 1).padStart(2, "0")}</span> + <div> + <h3>{item.type}</h3> + <p>{item.example}</p> + <small>{item.reason}</small> + </div> + <strong>{item.pressure}</strong> + </article> + ))} + </div> + + <aside class="lora-comparison" aria-labelledby="two-questions-title"> + <h3 id="two-questions-title">Keep two questions apart</h3> + <div> + <p> + <strong>Capacity:</strong> Can the update channel represent all the + changes the data asks for? Vary content topology and rank. If more rank + closes the gap, capacity is a plausible cause. + </p> + <p> + <strong>Optimization:</strong> Can training find a good update + efficiently? Thinking Machines found a large-batch LoRA penalty that + higher rank did not repair. Their proposed cause concerns the dynamics + of optimizing the matrix product, not a lack of adapter parameters. + </p> + </div> + <p class="lora-comparison-rule"> + For this experiment, hold global batch size fixed at 32. Changing batch + size would mix a separate optimization effect into the capacity test. + </p> + </aside> + </section> + + <section id="experiment" aria-labelledby="experiment-title"> + <h2 id="experiment-title">How to test it</h2> + <p> + Natural language hides too many variables at once. A substitution-cipher + task lets us keep vocabulary, sequence length, token count, and task + difficulty fixed while changing only the sharing structure. + </p> + + <figure class="lora-cipher-figure"> + <div> + <span>World</span><strong>W·042</strong> + <b>+</b> + <span>Input</span><strong>A C F B</strong> + <b>→</b> + <span>Target</span><strong>Q N Z R</strong> + </div> + <figcaption> + A world identifier selects the cipher. Evaluation uses new sequences, + so success requires learning the mapping rather than copying rows. + </figcaption> + </figure> + + <ol class="lora-experiment-steps"> + {experimentSteps.map((step, index) => ( + <li> + <span>{String(index + 1).padStart(2, "0")}</span> + <div><h3>{step.title}</h3><p>{step.text}</p></div> + </li> + ))} + </ol> + + <figure class="lora-run-grid"> + <div><strong>3</strong><span>topologies</span></div><b>×</b> + <div><strong>4</strong><span>update methods</span></div><b>×</b> + <div><strong>3</strong><span>learning rates</span></div><b>×</b> + <div><strong>2</strong><span>seeds</span></div><b>=</b> + <div><strong>72</strong><span>primary runs</span></div> + <figcaption>A small enough grid to finish, large enough to keep one lucky run from becoming the result.</figcaption> + </figure> + + <details class="lora-technical-note"> + <summary>Exact proposed setup</summary> + <p> + Use EleutherAI’s Pythia 160M model. Select 32 input tokens and 32 + disjoint output tokens from its existing vocabulary. Give every world + 24 balanced training sequences, eight validation sequences, and eight + frozen test sequences. Compare dense updates to the same attention and + feed-forward matrices targeted by all-layer LoRA at ranks 1, 4, and 16. + Keep global batch size fixed at 32 and select learning rates using + validation loss only. + </p> + </details> + </section> + + <section aria-labelledby="evidence-title"> + <h2 id="evidence-title">What would count as evidence?</h2> + <p> + A score alone would not tell us why one method won. The test should + connect behavior to the actual shape of the learned weight changes. + </p> + + <div class="lora-evidence-list"> + <article> + <h3>1. Behavior</h3> + <p>Evaluate unseen token sequences. Record accuracy, loss, and the training tokens required to reach 95 percent validation accuracy.</p> + </article> + <article> + <h3>2. Mechanism</h3> + <p>Decompose each dense weight update and measure how many components capture 95 percent of its energy. Fragmented data should produce a broader spectrum if the hypothesis is right.</p> + </article> + <article> + <h3>3. Damage check</h3> + <p>Measure perplexity on a fixed WikiText sample. A method that learns the cipher by damaging ordinary language has not found a clean solution.</p> + </article> + </div> + + <aside class="lora-falsification"> + <strong>The hypothesis loses if</strong> + <p> + sharing structure moves neither the minimum successful LoRA rank nor + the spectrum of dense weight updates after learning rate, convergence, + and parameter placement are controlled. A fragmented task solved + cleanly by low-rank LoRA would also count against the construction. + </p> + </aside> + </section> + + <section aria-labelledby="agents-title"> + <h2 id="agents-title">From synthetic ciphers to agents</h2> + <p> + The cipher test can show whether topology changes the rank frontier. It + cannot show that real post-training data has the same shape. The honest + route to application adds realism in stages. + </p> + + <ol class="lora-realism-ladder"> + <li><span>Phase 1</span><strong>Synthetic ciphers</strong><p>Isolate shared structure while keeping the data budget fixed.</p></li> + <li><span>Phase 2</span><strong>Fictional institutions</strong><p>Express coherent, clustered, and fragmented rules in ordinary language.</p></li> + <li><span>Phase 3</span><strong>Agent corrections</strong><p>Hold content fixed while comparing scalar rewards, preference pairs, and corrected answers.</p></li> + </ol> + + <aside class="lora-note"> + <strong>An architectural consequence</strong> + <p> + Separate adapters preserve the natural factorization between users, + domains, or institutions. Merging years of unrelated corrections into + one model asks a single update to absorb their union. If the hypothesis + holds, adapter architecture is also data architecture. + </p> + </aside> + </section> + + <section aria-labelledby="limits-title"> + <h2 id="limits-title">What this does not claim</h2> + <ul class="lora-limits"> + <li><strong>This is not yet a result.</strong> Thinking Machines showed that dataset size and adapter size interact. Content topology is a proposed refinement, not a conclusion from their experiments.</li> + <li><strong>Semantic rules are not matrix axes.</strong> “Independent update directions” is a geometric intuition. Neural networks can discover shared mechanisms that a human taxonomy misses.</li> + <li><strong>Capacity and optimization differ.</strong> Large-batch LoRA gaps can persist even when rank increases. That separate effect stays fixed here.</li> + <li><strong>Dense updates are a bounded baseline.</strong> The primary comparison updates exactly the matrices LoRA targets. Unrestricted full fine-tuning is a secondary diagnostic.</li> + </ul> + </section> + </div> + + <footer class="lora-sources"> + <h2>Sources</h2> + <a href="https://thinkingmachines.ai/blog/lora/" target="_blank" rel="noopener">Thinking Machines: LoRA Without Regret ↗</a> + <a href="https://arxiv.org/abs/2106.09685" target="_blank" rel="noopener">Hu et al.: LoRA: Low-Rank Adaptation of Large Language Models ↗</a> + </footer> + </article> + + <script src="/public/lora-topology.js"></script> + </div> + ); +} diff --git a/src/components/other-index.tsx b/src/components/other-index.tsx new file mode 100644 index 0000000..b96c157 --- /dev/null +++ b/src/components/other-index.tsx @@ -0,0 +1,58 @@ +import { getProfile } from "../data.ts"; +import { PageNav } from "./page-nav.tsx"; + +const entries = [ + { + index: "01", + href: "/endorsements", + title: "Endorsements", + description: "Projects and people endorsed through public fund.at records.", + meta: "AT Protocol", + external: false, + }, + { + index: "02", + href: "/type", + title: "Around typeface", + description: "A custom geometric display face drawn from the Ring's grammar.", + meta: "Typeface specimen", + external: false, + }, +]; + +export async function OtherIndex() { + const profile = await getProfile(); + + return ( + <div> + <PageNav profile={profile} active="other" /> + + <header class="other-index-header"> + <h1 class="page-title">Other</h1> + <p class="page-subtitle">Misc stuff.</p> + </header> + + <nav class="other-index-list" aria-label="Other pages"> + {entries.map((entry) => ( + <a + href={entry.href} + target={entry.external ? "_blank" : undefined} + rel={entry.external ? "noopener" : undefined} + class="other-index-entry" + key={entry.href} + > + <span class="other-index-number">{entry.index}</span> + <span class="other-index-copy"> + <strong> + {entry.title} + {entry.external && <span aria-hidden="true"> ↗</span>} + </strong> + <span>{entry.description}</span> + </span> + <span class="other-index-meta">{entry.meta}</span> + </a> + ))} + </nav> + </div> + ); +} diff --git a/src/components/page-nav.tsx b/src/components/page-nav.tsx index f207cbb..3fe93a0 100644 --- a/src/components/page-nav.tsx +++ b/src/components/page-nav.tsx @@ -3,10 +3,10 @@ import type { ProfileData } from "../data.ts"; export type PageNavSection = | "blog" | "about" - | "endorsements" - | "annotations" - | "semble" - | "bluesky"; + | "knowledge" + | "code" + | "bluesky" + | "other"; const links: Array<{ section: PageNavSection; @@ -15,21 +15,25 @@ const links: Array<{ }> = [ { section: "blog", href: "/blog", label: "Blog" }, { section: "about", href: "/about", label: "About" }, - { section: "endorsements", href: "/endorsements", label: "Endorsements" }, - { section: "annotations", href: "/annotations", label: "Annotations" }, - { section: "semble", href: "/semble", label: "Semble" }, - { section: "bluesky", href: "/bluesky", label: "Bluesky" }, + { section: "code", href: "/code", label: "Code" }, + { section: "knowledge", href: "/knowledge", label: "Knowledge" }, + { section: "other", href: "/other", label: "Other" }, ]; export function PageNav({ profile, active, + centered = false, }: { profile: ProfileData | null; active?: PageNavSection; + centered?: boolean; }) { return ( - <nav class="site-nav" aria-label="Site navigation"> + <nav + class={`site-nav${centered ? " site-nav-centered" : ""}`} + aria-label="Site navigation" + > {profile?.avatar && ( <a href="/" class="nav-avatar" aria-label="Home"> <img src={profile.avatar} alt="" /> diff --git a/src/components/protocol-links.tsx b/src/components/protocol-links.tsx new file mode 100644 index 0000000..9e31c0e --- /dev/null +++ b/src/components/protocol-links.tsx @@ -0,0 +1,39 @@ +export function ProtocolLinks({ + atUri, + leafletUrl, + avatarUrl, + className, +}: { + atUri: string; + leafletUrl: string; + avatarUrl?: string; + className?: string; +}) { + return ( + <nav + class={`protocol-links${className ? ` ${className}` : ""}`} + aria-label="Protocol versions" + > + <a + class="protocol-link-leaflet" + href={leafletUrl} + target="_blank" + rel="noopener" + > + leaflet + </a> + <a class="protocol-mark" href="/" aria-label="Home"> + {avatarUrl ? <img src={avatarUrl} alt="" /> : <span aria-hidden="true" />} + </a> + <a + class="protocol-link-atproto" + href={`https://atproto.md/${atUri}`} + target="_blank" + rel="noopener" + aria-label="View the AT Protocol record" + > + at:// + </a> + </nav> + ); +} diff --git a/src/components/semble.tsx b/src/components/semble.tsx deleted file mode 100644 index cb8b452..0000000 --- a/src/components/semble.tsx +++ /dev/null @@ -1,180 +0,0 @@ -// Semble page — renders canonical network.cosmik records from Cameron's PDS. - -import { - getProfile, - getSembleData, - type SembleCard, - type SembleCollection, -} from "../data.ts"; -import { PageNav } from "./page-nav.tsx"; - -function escapeHtml(str: string): string { - return str - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """); -} - -function displayDomain(url: string): string { - try { - return new URL(url).hostname.replace(/^www\./, ""); - } catch { - return url; - } -} - -function sembleCollectionUrl(collection: SembleCollection): string { - if (!collection.uri) return "https://semble.so"; - // uri format: at://did:plc:xxx/network.cosmik.collection/rkey - const rkey = collection.uri.split("/").pop(); - const handle = collection.author?.handle ?? "cameron.stream"; - return `https://semble.so/profile/${handle}/collections/${rkey}`; -} - -function sembleCardUrl(card: SembleCard): string { - return card.url; -} - -export async function Semble() { - const [{ collections, cards, available }, profile] = await Promise.all([ - getSembleData(30), - getProfile(), - ]); - - return ( - <div> - <PageNav profile={profile} active="semble" /> - - <h1 class="page-title">Semble</h1> - <p class="page-subtitle"> - Collections and bookmarks on{" "} - <a - href="https://semble.so" - target="_blank" - rel="noopener" - > - Semble - </a> - , stored as{" "} - <a - href="https://docs.cosmik.network/semble/developer-guide/semble-lexicon-reference" - target="_blank" - rel="noopener" - > - network.cosmik.card - </a>{" "} - records on my PDS. - </p> - - {collections.length > 0 && ( - <div style="margin-bottom:2em"> - <h2 style="font-size:1.1rem;font-weight:600;margin-bottom:0.75em"> - Collections - </h2> - <div class="semble-collections"> - {collections.map((c) => ( - <a - href={sembleCollectionUrl(c)} - target="_blank" - rel="noopener" - class="semble-collection-card" - key={c.id} - > - <div class="semble-collection-name"> - {escapeHtml(c.name)} - </div> - {c.description && ( - <div class="semble-collection-desc"> - {escapeHtml(c.description)} - </div> - )} - <div class="semble-collection-meta"> - {c.cardCount} card{c.cardCount !== 1 ? "s" : ""} - </div> - </a> - ))} - </div> - </div> - )} - - <div> - <h2 style="font-size:1.1rem;font-weight:600;margin-bottom:0.75em"> - Recent cards - </h2> - {!available ? ( - <p style="color:var(--site-text-muted);font-size:0.9rem"> - Semble records are temporarily unavailable. - </p> - ) : cards.length === 0 ? ( - <p style="color:var(--site-text-muted);font-size:0.9rem"> - No cards yet. - </p> - ) : ( - <div class="semble-cards"> - {cards - .filter((c) => c.type === "URL") - .map((card) => { - const date = new Date(card.createdAt).toLocaleDateString( - "en-US", - { month: "short", day: "numeric", year: "numeric" } - ); - const domain = displayDomain(card.url); - const title = - card.cardContent?.title || domain; - - return ( - <div class="semble-card-item" key={card.id}> - <div> - <a - href={sembleCardUrl(card)} - target="_blank" - rel="noopener" - style="color:var(--site-text);text-decoration:none;font-weight:600;font-size:0.9rem;line-height:1.3" - > - {escapeHtml(title)} - </a> - </div> - {card.cardContent?.description && ( - <p style="font-size:0.8125rem;color:var(--site-text-secondary);margin:0.2em 0;line-height:1.4"> - {escapeHtml( - card.cardContent.description.length > 160 - ? card.cardContent.description.slice(0, 160) + "..." - : card.cardContent.description - )} - </p> - )} - {card.note?.text && ( - <blockquote class="semble-card-note"> - {escapeHtml(card.note.text)} - </blockquote> - )} - <div class="semble-card-meta"> - <span>{domain}</span> - <span>{date}</span> - {card.collections.length > 0 && ( - <span> - in{" "} - {card.collections.map((c) => c.name).join(", ")} - </span> - )} - </div> - </div> - ); - })} - </div> - )} - </div> - - <footer class="page-footer"> - <a - href={`https://semble.so/profile/cameron.stream`} - target="_blank" - rel="noopener" - > - View full profile on Semble - </a> - </footer> - </div> - ); -} diff --git a/src/components/site.tsx b/src/components/site.tsx index 464b1fd..d53a6b9 100644 --- a/src/components/site.tsx +++ b/src/components/site.tsx @@ -3,6 +3,7 @@ import { raw } from "hono/html"; import { getProfile } from "../data.ts"; +import { PageNav } from "./page-nav.tsx"; import { Webring } from "./webring.tsx"; export async function Site() { @@ -20,31 +21,9 @@ export async function Site() { <h1 style="margin:0.5em 0 0;font-size:clamp(1.75rem,5vw,3rem);font-weight:700;letter-spacing:0.06em"> {profile?.displayName ?? "Cameron Pfiffer"} </h1> - <p style="margin:0.25em 0;color:var(--site-text-secondary);font-size:1.2rem"> - <a - href={`https://bsky.app/profile/${profile?.handle ?? "cameron.stream"}`} - target="_blank" - rel="noopener" - style="color:inherit;text-decoration:none" - > - @{profile?.handle ?? "cameron.stream"} - </a> - </p> </div> - <nav class="site-nav" style="justify-content:center;margin-top:2em;flex-wrap:wrap"> - <a href="/">Home</a> - <a href="/blog">Blog</a> - <a href="/about">About</a> - <a href="/endorsements">Endorsements</a> - <a href="/annotations">Annotations</a> - <a href="/semble">Semble</a> - <a href="/bluesky">Bluesky</a> - <a href="/type">Type</a> - <a href="https://github.com/just-cameron" target="_blank" rel="noopener"> - GitHub - </a> - </nav> + <PageNav profile={null} centered /> <Webring name="Letta" slug="lettamates" from={ringFrom} /> </div> diff --git a/src/components/thoughtstore-guide.tsx b/src/components/thoughtstore-guide.tsx new file mode 100644 index 0000000..554274c --- /dev/null +++ b/src/components/thoughtstore-guide.tsx @@ -0,0 +1,314 @@ +import { getProfile } from "../data.ts"; +import { PageNav } from "./page-nav.tsx"; + +const contractChecks = [ + { + promise: "Repeat one observation", + test: "Append the same source and idempotency key twice.", + expected: "One stored event", + state: "Proven sequentially", + tone: "proven", + }, + { + promise: "Race one observation", + test: "Make two writers append the same observation at the same time.", + expected: "One stored event", + state: "Not yet proven", + tone: "gap", + }, + { + promise: "Keep evidence immutable", + test: "Attempt to update or delete an accepted event.", + expected: "Write rejected", + state: "Not yet enforced", + tone: "gap", + }, + { + promise: "Survive restart", + test: "Append, close the store, reopen it, and read.", + expected: "Same event returns", + state: "Proven locally", + tone: "proven", + }, + { + promise: "Rebuild a view", + test: "Discard a projection and fold the event log again.", + expected: "Same projection hash", + state: "Proven locally", + tone: "proven", + }, + { + promise: "Synchronize safely", + test: "Reconnect concurrent writers after conflicting offline work.", + expected: "No lost or duplicate evidence", + state: "Not yet proven", + tone: "gap", + }, +]; + +export async function ThoughtStoreGuide() { + const profile = await getProfile(); + + return ( + <div class="store-guide-shell"> + <PageNav profile={profile} /> + + <article class="store-guide"> + <header class="store-header"> + <a href="/artifacts" class="store-backlink">Artifacts / Architecture 004</a> + <h1>The promise and the engine</h1> + <p class="store-deck"> + Why <code>ThoughtStore</code> should define what persistence means while Jazz + remains the first system that makes those promises real. + </p> + <div class="store-meta"> + <span>thought stream</span> + <span>Precommit decision</span> + <span>July 2026</span> + </div> + </header> + + <div class="store-prose"> + <section aria-labelledby="short-answer"> + <h2 id="short-answer">The short answer</h2> + <p class="store-lede"> + <strong>ThoughtStore is a set of promises.</strong> Jazz is the engine we are + using to fulfill them. + </p> + <p> + The application needs facts such as “this observation is stored exactly + once,” “accepted evidence cannot be rewritten,” and “a completed write + survives restart.” Those are thought stream rules. They should not change + accidentally when Jazz changes its API, driver, permissions, or sync behavior. + </p> + <p> + This does not add another process, database, or network hop. In the running + program, <code>JazzThoughtStore</code> is still the concrete object doing the + work. The contract is the line above it that says which outcomes count as correct. + </p> + + <figure class="store-stack-figure"> + <div class="store-stack-layer store-stack-callers"> + <span class="store-layer-label">THOUGHT STREAM</span> + <div><span>connectors</span><span>workers</span><span>inspector</span></div> + <p>Ask to append, read, lease, flush, and rebuild.</p> + </div> + <div class="store-contract-line"> + <span>ThoughtStore contract</span> + <small>observable promises + shared tests</small> + </div> + <div class="store-stack-layer store-stack-adapter"> + <span class="store-layer-label">ADAPTER</span> + <strong>JazzThoughtStore</strong> + <p>Translates each promise into Jazz queries and writes.</p> + </div> + <div class="store-stack-layer store-stack-engine"> + <span class="store-layer-label">ENGINE</span> + <strong>Jazz relational tables</strong> + <p>Persistent SQLite locally; sync may be added deliberately later.</p> + </div> + <figcaption> + One process, one concrete store. The thin line is a behavioral boundary, + not a new runtime layer. + </figcaption> + </figure> + </section> + + <section aria-labelledby="interface-title"> + <h2 id="interface-title">An interface names operations. A contract defines outcomes.</h2> + <p> + The TypeScript interface already exists. It says that every store must offer + methods such as <code>appendEvent</code>, <code>listEvents</code>, + <code>flush</code>, and <code>close</code>. That is useful, but method names alone + do not tell us what happens under pressure. + </p> + + <div class="store-equation" aria-label="Definition of the complete ThoughtStore contract"> + <span>ThoughtStore contract</span> + <b>=</b> + <span>TypeScript interface</span> + <b>+</b> + <span>written invariants</span> + <b>+</b> + <span>conformance tests</span> + </div> + + <p> + The written invariant might say that <code>(source, idempotencyKey)</code> is + unique. The conformance test tries to break that invariant. The Jazz adapter + passes only when the observed result is correct. + </p> + </section> + + <section aria-labelledby="race-title"> + <h2 id="race-title">The current race makes the distinction concrete</h2> + <p> + Today, the Jazz adapter checks whether an event exists and then inserts it. + The existing test proves that two <em>sequential</em> calls deduplicate. Two + concurrent writers are a different case. + </p> + + <figure class="store-race-figure"> + <div class="race-column"> + <strong>Writer A</strong> + <span>query: nothing found</span> + <span class="race-insert">insert event α</span> + </div> + <div class="race-time" aria-hidden="true"> + <span>1</span><i></i><span>2</span> + </div> + <div class="race-column"> + <strong>Writer B</strong> + <span>query: nothing found</span> + <span class="race-insert">insert event β</span> + </div> + <div class="race-result"> + <span>BACKEND RESULT</span> + <strong>Two rows may exist.</strong> + </div> + <div class="race-required"> + <span>CONTRACT REQUIRES</span> + <strong>One observation becomes one event.</strong> + </div> + <figcaption> + The repair may be entirely Jazz-specific: a uniqueness constraint, + serialized append path, transaction, or another atomic primitive. Callers + should depend on the one-event outcome, not on which repair Jazz needs. + </figcaption> + </figure> + </section> + + <section aria-labelledby="tests-title"> + <h2 id="tests-title">One suite turns the promise into evidence</h2> + <p> + A conformance suite runs the same behavioral tests against a store factory. + For now that factory always creates <code>JazzThoughtStore</code>. A future + backend is possible, but portability is not the reason to do this. The reason + is to know whether the current backend actually satisfies thought stream. + </p> + + <div class="store-checks" role="table" aria-label="ThoughtStore conformance checks"> + <div class="store-check-head" role="row"> + <span role="columnheader">Promise</span> + <span role="columnheader">Break-it test</span> + <span role="columnheader">Required result</span> + <span role="columnheader">Today</span> + </div> + {contractChecks.map((check) => ( + <div class="store-check-row" role="row"> + <strong role="cell">{check.promise}</strong> + <span role="cell">{check.test}</span> + <span role="cell">{check.expected}</span> + <span role="cell" class={`store-state store-state-${check.tone}`}>{check.state}</span> + </div> + ))} + </div> + + <aside class="store-note"> + <strong>Important restraint</strong> + <p> + A failed test does not mean “replace Jazz.” It means either repair the Jazz + adapter or narrow the promise. Architecture becomes honest at that moment. + </p> + </aside> + </section> + + <section aria-labelledby="history-title"> + <h2 id="history-title">The same rule protects historical meaning</h2> + <p> + The current <code>agents</code> table is operational state. Saving version 2 + under the same agent key updates the row that held version 1. An old run can + still say <code>agentVersion: 1</code>, but the full version-1 declaration may + no longer exist. + </p> + + <figure class="store-history-figure"> + <div class="history-current"> + <span class="history-label">CURRENT UPSERT</span> + <div class="history-row history-row-old"> + <small>10:00</small> + <strong>document-structure</strong> + <span>version 1</span> + </div> + <div class="history-overwrite" aria-hidden="true">↓ overwrite same key</div> + <div class="history-row"> + <small>14:00</small> + <strong>document-structure</strong> + <span>version 2</span> + </div> + <p>Only the latest declaration remains easy to resolve.</p> + </div> + <div class="history-contract"> + <span class="history-label">CONTRACTUAL HISTORY</span> + <div class="history-row"> + <small>10:00</small> + <strong>document-structure@1</strong> + <span>hash 7e4…</span> + </div> + <div class="history-row"> + <small>14:00</small> + <strong>document-structure@2</strong> + <span>hash 19b…</span> + </div> + <p>Each run resolves the exact immutable revision it used.</p> + </div> + <figcaption> + “What produced this output?” must remain answerable after configuration changes. + The current-agent row can stay as a mutable projection beside the revision history. + </figcaption> + </figure> + </section> + + <section aria-labelledby="changes-title"> + <h2 id="changes-title">What this decision changes</h2> + <ol class="store-change-list"> + <li> + <span>01</span> + <div><strong>Write the invariants beside the interface.</strong><p>Define exact observable behavior for events, revisions, cursors, runs, durability, ordering, and replay.</p></div> + </li> + <li> + <span>02</span> + <div><strong>Build one conformance suite.</strong><p>Run it against the Jazz adapter locally. Add remote and multi-writer cases before claiming sync safety.</p></div> + </li> + <li> + <span>03</span> + <div><strong>Keep immutable evidence separate from mutable operations.</strong><p>Events, document versions, traces, and config revisions are history. Cursors, leases, active bindings, and projections are current state.</p></div> + </li> + <li> + <span>04</span> + <div><strong>Let Jazz solve Jazz problems.</strong><p>Atomicity, permissions, transactions, and sync remain adapter work. Connectors and agents only see the promised outcomes.</p></div> + </li> + </ol> + </section> + + <section aria-labelledby="not-title"> + <h2 id="not-title">What this does not mean</h2> + <dl class="store-not-list"> + <div><dt>No second service</dt><dd>The adapter runs in the same thought stream process.</dd></div> + <div><dt>No database rewrite</dt><dd>Jazz remains the only implemented backend.</dd></div> + <div><dt>No lowest-common-denominator API</dt><dd>The contract should express thought stream’s actual needs, even when they require Jazz-specific work.</dd></div> + <div><dt>No speculative portability project</dt><dd>Another adapter is built only if a real need appears.</dd></div> + </dl> + + <p class="store-conclusion"> + The useful boundary is simple: <strong>thought stream owns the meaning of a + successful write; Jazz owns how that write becomes durable.</strong> + </p> + </section> + </div> + + <footer class="store-sources"> + <h2>Source surface</h2> + <p> + Based on the uncommitted thought stream implementation as of July 14, 2026: + <code>src/store/types.ts</code>, <code>src/jazz/store.ts</code>, + <code>src/jazz/schema.ts</code>, <code>src/jazz/permissions.ts</code>, + <code>spec/events.md</code>, <code>spec/jazz.md</code>, and the current Jazz and + acceptance tests. + </p> + <a href="/artifacts">Return to artifacts</a> + </footer> + </article> + </div> + ); +} diff --git a/src/data.ts b/src/data.ts index 619bbef..3b18650 100644 --- a/src/data.ts +++ b/src/data.ts @@ -4,7 +4,6 @@ import { AtUri } from "@atproto/syntax"; import { UnicodeString } from "@atproto/api"; import { cacheGet, cacheSet } from "./cache.ts"; -const PDS = "https://bsky.social"; const COLLECTION = "site.standard.document"; const PUBLICATION_URI = process.env.PUBLICATION_URI || @@ -37,17 +36,37 @@ function getDid(): string { return process.env.CAMERON_DID || "did:plc:gfrmhdmjvxn2sjedzboeudef"; } +let pdsEndpointPromise: Promise<string> | undefined; + +async function getPdsEndpoint(): Promise<string> { + if (process.env.ATP_SERVICE) return process.env.ATP_SERVICE.replace(/\/$/, ""); + pdsEndpointPromise ??= fetch(`https://plc.directory/${getDid()}`) + .then(async (response) => { + if (!response.ok) throw new Error(`DID document lookup failed: HTTP ${response.status}`); + const document = await response.json() as { + service?: Array<{ id?: string; type?: string; serviceEndpoint?: string }>; + }; + const service = document.service?.find((item) => + item.id === "#atproto_pds" || item.type === "AtprotoPersonalDataServer" + ); + if (!service?.serviceEndpoint) throw new Error("DID document has no ATProto PDS endpoint"); + return service.serviceEndpoint.replace(/\/$/, ""); + }); + return pdsEndpointPromise; +} + async function getRecord( repo: string, collection: string, rkey: string ): Promise<Record<string, unknown> | null> { + const pds = await getPdsEndpoint(); const cacheKey = `record:${repo}:${collection}:${rkey}`; const cached = await cacheGet(cacheKey); if (cached !== undefined) return cached as Record<string, unknown> | null; const res = await fetch( - `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(repo)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}` + `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(repo)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}` ); if (!res.ok) { await cacheSet(cacheKey, null, { life: "minutes" }); @@ -65,6 +84,7 @@ async function listRecords( limit = 100, options: { required?: boolean } = {} ): Promise<Array<{ uri: string; value: Record<string, unknown> }>> { + const pds = await getPdsEndpoint(); const cacheKey = `records:${repo}:${collection}:${limit}`; const cached = await cacheGet(cacheKey); if (cached !== undefined) { @@ -72,7 +92,7 @@ async function listRecords( } const res = await fetch( - `${PDS}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(repo)}&collection=${encodeURIComponent(collection)}&limit=${limit}` + `${pds}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(repo)}&collection=${encodeURIComponent(collection)}&limit=${limit}` ); if (!res.ok) { if (options.required) { @@ -353,7 +373,8 @@ export async function listBlogPosts(): Promise<BlogPost[]> { const did = getDid(); const records = await listRecords(did, COLLECTION); return records - .filter((r) => (r.value.site as string) === PUBLICATION_URI) // only our publication + .filter((r) => (r.value.site as string) === PUBLICATION_URI) + .filter((r) => Array.isArray(r.value.tags) && r.value.tags.includes("blog")) .map((r) => parseDocument(r.uri, r.value)) .filter((p) => p.slug) .sort( @@ -369,6 +390,19 @@ export async function getBlogPost(slug: string): Promise<BlogPost | null> { return posts.find((p) => p.slug === slug) ?? null; } +export async function getKnowledgeDocument(slug: string): Promise<BlogPost | null> { + const did = getDid(); + const rkey = `knowledge-${slug}`; + const record = await getRecord(did, COLLECTION, rkey); + if (!record || record.site !== PUBLICATION_URI) return null; + const document = parseDocument(`at://${did}/${COLLECTION}/${rkey}`, record); + return document.slug === slug ? document : null; +} + +export async function getKnowledgeNow(): Promise<BlogPost | null> { + return getKnowledgeDocument("now"); +} + // --- About --- export async function getAbout(): Promise<string | null> { diff --git a/src/index.tsx b/src/index.tsx index 295a001..26b89ce 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,15 +9,21 @@ import { BlogIndex } from "./components/blog-index.tsx"; import { BlogPost } from "./components/blog-post.tsx"; import { About } from "./components/about.tsx"; import { Endorsements } from "./components/endorsements.tsx"; -import { Annotations } from "./components/annotations.tsx"; -import { Semble } from "./components/semble.tsx"; +import { OtherIndex } from "./components/other-index.tsx"; +import { CodeActivity } from "./components/code-activity.tsx"; import { Bluesky } from "./components/bluesky.tsx"; import { Travel } from "./components/travel.tsx"; import { TypeSpecimen } from "./components/type-specimen.tsx"; import { Artifacts } from "./components/artifacts.tsx"; import { TinkerGuide } from "./components/tinker-guide.tsx"; +import { LoraGuide } from "./components/lora-guide.tsx"; +import { LoraTopology } from "./components/lora-topology.tsx"; +import { ThoughtStoreGuide } from "./components/thoughtstore-guide.tsx"; +import { KnowledgeIndex } from "./components/knowledge-index.tsx"; +import { getKnowledgeEntry, KnowledgeEntry } from "./components/knowledge-entry.tsx"; +import { KnowledgeDocument } from "./components/knowledge-document.tsx"; import "./types.ts"; -import { getBlogPost } from "./data.ts"; +import { getBlogPost, getKnowledgeDocument } from "./data.ts"; type JSXElement = | import("hono/utils/html").HtmlEscapedString @@ -37,11 +43,13 @@ function Shell({ title, wide = false, noIndex = false, + stylesheets = [], }: { children: JSXElement; title?: string; wide?: boolean; noIndex?: boolean; + stylesheets?: string[]; }) { const pageTitle = title ? `${title} — cameron.stream` : "cameron.stream"; return ( @@ -59,6 +67,7 @@ function Shell({ <link rel="stylesheet" href="/public/site.css" /> <link rel="stylesheet" href="/public/type.css" /> <link rel="stylesheet" href="/public/artifacts.css" /> + {stylesheets.map((href) => <link rel="stylesheet" href={href} />)} {raw(`<script>${themeInitScript}</script>`)} {raw(`<style> .page-container { @@ -136,10 +145,10 @@ app.get("/endorsements", async (c) => { }); }); -app.get("/annotations", async (c) => { +app.get("/other", async (c) => { const stream = renderToReadableStream( - <Shell title="Annotations"> - <Annotations /> + <Shell title="Other"> + <OtherIndex /> </Shell> ); return c.body(stream, { @@ -150,10 +159,10 @@ app.get("/annotations", async (c) => { }); }); -app.get("/semble", async (c) => { +app.get("/code", async (c) => { const stream = renderToReadableStream( - <Shell title="Semble"> - <Semble /> + <Shell title="Code"> + <CodeActivity /> </Shell> ); return c.body(stream, { @@ -164,6 +173,47 @@ app.get("/semble", async (c) => { }); }); +app.get("/library", (c) => c.redirect("/knowledge#links", 301)); + +app.get("/knowledge", async (c) => { + const includeDrafts = process.env.KNOWLEDGE_INCLUDE_DRAFTS === "1"; + const stream = renderToReadableStream( + <Shell title="Knowledge" noIndex={includeDrafts}> + <KnowledgeIndex /> + </Shell> + ); + return c.body(stream, { + headers: { + "Content-Type": "text/html; charset=UTF-8", + "Transfer-Encoding": "chunked", + ...(includeDrafts ? { "X-Robots-Tag": "noindex, nofollow, noarchive" } : {}), + }, + }); +}); + +app.get("/knowledge/:slug", async (c) => { + const slug = c.req.param("slug"); + const entry = await getKnowledgeEntry(slug); + const remoteDocument = entry ? null : await getKnowledgeDocument(slug); + if (!entry && !remoteDocument) return c.notFound(); + + const stream = renderToReadableStream( + <Shell title={(entry ?? remoteDocument)!.title} noIndex={entry?.draft ?? false}> + {entry ? <KnowledgeEntry entry={entry} /> : <KnowledgeDocument document={remoteDocument!} />} + </Shell> + ); + return c.body(stream, { + headers: { + "Content-Type": "text/html; charset=UTF-8", + "Transfer-Encoding": "chunked", + ...(entry?.draft ? { "X-Robots-Tag": "noindex, nofollow, noarchive" } : {}), + }, + }); +}); + +app.get("/semble", (c) => c.redirect("/knowledge#links", 301)); +app.get("/annotations", (c) => c.redirect("/knowledge#annotations", 301)); + app.get("/travel", async (c) => { const stream = renderToReadableStream( @@ -237,6 +287,64 @@ app.get("/artifacts/tinker", async (c) => { }); }); +app.get("/artifacts/lora-without-regret", async (c) => { + const stream = renderToReadableStream( + <Shell + title="LoRA Without Regret field guide" + wide + noIndex + stylesheets={["/public/lora-artifact.css"]} + > + <LoraGuide /> + </Shell> + ); + return c.body(stream, { + headers: { + "Content-Type": "text/html; charset=UTF-8", + "Transfer-Encoding": "chunked", + "X-Robots-Tag": "noindex, nofollow, noarchive", + }, + }); +}); + +app.get("/artifacts/what-fits-in-a-lora", async (c) => { + const stream = renderToReadableStream( + <Shell + title="What Fits in a LoRA?" + noIndex + stylesheets={["/public/lora-topology.css"]} + > + <LoraTopology /> + </Shell> + ); + return c.body(stream, { + headers: { + "Content-Type": "text/html; charset=UTF-8", + "Transfer-Encoding": "chunked", + "X-Robots-Tag": "noindex, nofollow, noarchive", + }, + }); +}); + +app.get("/artifacts/thoughtstore-and-jazz", async (c) => { + const stream = renderToReadableStream( + <Shell + title="ThoughtStore and Jazz" + noIndex + stylesheets={["/public/thoughtstore-guide.css"]} + > + <ThoughtStoreGuide /> + </Shell> + ); + return c.body(stream, { + headers: { + "Content-Type": "text/html; charset=UTF-8", + "Transfer-Encoding": "chunked", + "X-Robots-Tag": "noindex, nofollow, noarchive", + }, + }); +}); + app.get("/blog/:slug", async (c) => { // Redirect old /blog/slug URLs to /slug return c.redirect(`/${c.req.param("slug")}`, 301); diff --git a/src/knowledge.ts b/src/knowledge.ts new file mode 100644 index 0000000..27cf1ca --- /dev/null +++ b/src/knowledge.ts @@ -0,0 +1,238 @@ +import { createHash } from "node:crypto"; +import { lstat, readdir, readFile, realpath } from "node:fs/promises"; +import { resolve } from "node:path"; +import matter from "gray-matter"; +import { z } from "zod"; + +const KnowledgeSourceSchema = z.object({ + title: z.string().trim().min(1), + url: z.string().url(), +}); + +const KnowledgeMetadataSchema = z.object({ + title: z.string().trim().min(1), + slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), + summary: z.string().trim().min(1).max(500), + kind: z.enum([ + "concept", + "journal", + "lesson", + "organization", + "person", + "place", + "practice", + "project", + ]), + status: z.enum(["active", "evolving", "historical"]), + claimMode: z.enum(["factual", "perspective", "mixed"]), + perspectiveOwner: z.string().trim().min(1).optional(), + confidence: z.enum(["high", "medium", "low"]), + topics: z.array(z.string().trim().min(1)).default([]), + related: z.array(z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)).default([]), + sources: z.array(KnowledgeSourceSchema).default([]), + aiAssisted: z.literal(true), + generatedBy: z.string().trim().min(1), + sourceDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(), + updated: z.coerce.date(), + publishedAt: z.coerce.date().optional(), + reviewStatus: z.enum(["draft", "approved"]), + reviewedBy: z.string().trim().min(1).optional(), + reviewedAt: z.coerce.date().optional(), + reviewedContentDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(), + reviewReceiptDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(), +}); + +export type KnowledgeSource = z.infer<typeof KnowledgeSourceSchema>; +export type KnowledgeMetadata = z.infer<typeof KnowledgeMetadataSchema>; + +export interface KnowledgeEntry extends KnowledgeMetadata { + body: string; + draft: boolean; + backlinks: string[]; +} + +export interface KnowledgeGraph { + entries: KnowledgeEntry[]; + bySlug: Map<string, KnowledgeEntry>; + errors: string[]; +} + +function assertPublishable(metadata: KnowledgeMetadata, sourceName: string): void { + if (metadata.claimMode !== "factual" && !metadata.perspectiveOwner) { + throw new Error(`${sourceName}: perspective entries require perspectiveOwner`); + } + + if (metadata.claimMode !== "perspective" && metadata.sources.length === 0) { + throw new Error(`${sourceName}: factual claims require at least one public source`); + } + + if (metadata.reviewStatus === "approved") { + if ( + !metadata.reviewedBy || + !metadata.reviewedAt || + !metadata.publishedAt || + !metadata.reviewedContentDigest || + !metadata.reviewReceiptDigest + ) { + throw new Error( + `${sourceName}: approved entries require reviewer, time, content digest, and receipt digest`, + ); + } + } +} + +export function parseKnowledgeDocument( + source: string, + sourceName: string, + draft: boolean, +): KnowledgeEntry { + const parsed = matter(source); + const metadata = KnowledgeMetadataSchema.parse(parsed.data); + assertPublishable(metadata, sourceName); + + if (!draft && metadata.reviewStatus !== "approved") { + throw new Error(`${sourceName}: published entries must be approved`); + } + + if (draft && metadata.reviewStatus !== "draft") { + throw new Error(`${sourceName}: staged entries must remain drafts`); + } + + if (!parsed.content.trim()) { + throw new Error(`${sourceName}: entry body is empty`); + } + + return { + ...metadata, + body: parsed.content.trim(), + draft, + backlinks: [], + }; +} + +async function readDirectory( + path: string, + draft: boolean, +): Promise<{ entries: KnowledgeEntry[]; errors: string[] }> { + let files: string[]; + try { + files = (await readdir(path)).filter((file) => file.endsWith(".md")).sort(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return { entries: [], errors: [] }; + throw error; + } + + const realRoot = await realpath(path); + + const results = await Promise.all( + files.map(async (file): Promise<{ entry?: KnowledgeEntry; error?: string }> => { + try { + const filePath = resolve(path, file); + const stats = await lstat(filePath); + if (stats.isSymbolicLink() || !stats.isFile()) { + throw new Error(`${file}: knowledge inputs must be regular files`); + } + const realFile = await realpath(filePath); + if (!realFile.startsWith(`${realRoot}/`)) { + throw new Error(`${file}: resolved path escapes the knowledge directory`); + } + const source = await readFile(filePath, "utf8"); + return { entry: parseKnowledgeDocument(source, file, draft) }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }), + ); + + return { + entries: results.flatMap((result) => result.entry ? [result.entry] : []), + errors: results.flatMap((result) => result.error ? [result.error] : []), + }; +} + +export function knowledgeContentDigest(entry: KnowledgeEntry): string { + const projection = { + title: entry.title, + slug: entry.slug, + summary: entry.summary, + kind: entry.kind, + status: entry.status, + claimMode: entry.claimMode, + perspectiveOwner: entry.perspectiveOwner, + confidence: entry.confidence, + topics: entry.topics, + related: entry.related, + sources: entry.sources, + aiAssisted: entry.aiAssisted, + generatedBy: entry.generatedBy, + sourceDigest: entry.sourceDigest, + updated: entry.updated.toISOString(), + body: entry.body, + }; + return `sha256:${createHash("sha256").update(JSON.stringify(projection)).digest("hex")}`; +} + +export async function loadKnowledgeGraph(options?: { + root?: string; + includeDrafts?: boolean; +}): Promise<KnowledgeGraph> { + const root = options?.root ?? resolve(process.cwd(), "knowledge"); + const includeDrafts = + options?.includeDrafts ?? process.env.KNOWLEDGE_INCLUDE_DRAFTS === "1"; + const errors: string[] = []; + + const load = async (directory: string, draft: boolean): Promise<KnowledgeEntry[]> => { + try { + const result = await readDirectory(resolve(root, directory), draft); + errors.push(...result.errors); + return result.entries; + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + return []; + } + }; + + const published = await load("published", false); + const drafts = includeDrafts ? await load("staged", true) : []; + const draftSlugs = new Set(drafts.map((entry) => entry.slug)); + const entries = [ + ...published.filter((entry) => !draftSlugs.has(entry.slug)), + ...drafts, + ]; + const bySlug = new Map<string, KnowledgeEntry>(); + + for (const entry of entries) { + const existing = bySlug.get(entry.slug); + if (existing) { + errors.push(`duplicate slug ${entry.slug}`); + continue; + } + bySlug.set(entry.slug, entry); + } + + for (const entry of entries) { + for (const relatedSlug of entry.related) { + const related = bySlug.get(relatedSlug); + if (!related) { + errors.push(`${entry.slug}: related entry ${relatedSlug} does not exist`); + continue; + } + related.backlinks.push(entry.slug); + } + } + + for (const entry of entries) entry.backlinks.sort(); + entries.sort((a, b) => a.title.localeCompare(b.title)); + + return { entries, bySlug, errors }; +} + +export function formatKnowledgeDate(date: Date): string { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }).format(date); +}