From a26490e5e4527f5018c27bc4c9f5338f42c915d0 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 24 Sep 2026 11:24:33 +0200 Subject: [PATCH] core: domain model, ports, vault parsing; contract suites for note store, vector index, catalog, embeddings Co-Authored-By: Claude Fable 5.1 --- Directory.Build.props | 10 ++ src/Wiki.Core/Model/Answer.cs | 20 +++ src/Wiki.Core/Model/Document.cs | 76 +++++++++ src/Wiki.Core/Model/Health.cs | 4 + src/Wiki.Core/Model/Judgement.cs | 36 +++++ src/Wiki.Core/Model/RawSource.cs | 29 ++++ src/Wiki.Core/Model/Retrieval.cs | 16 ++ src/Wiki.Core/Parsing/DocumentParser.cs | 72 +++++++++ src/Wiki.Core/Parsing/Frontmatter.cs | 134 ++++++++++++++++ src/Wiki.Core/Parsing/ParagraphChunker.cs | 63 ++++++++ src/Wiki.Core/Parsing/WikiLinks.cs | 52 +++++++ src/Wiki.Core/Ports/Ports.cs | 90 +++++++++++ .../Wiki.Contracts/DocumentCatalogContract.cs | 112 ++++++++++++++ test/Wiki.Contracts/EmbeddingModelContract.cs | 49 ++++++ test/Wiki.Contracts/NoteStoreContract.cs | 68 ++++++++ test/Wiki.Contracts/VaultFixture.cs | 38 +++++ test/Wiki.Contracts/VectorIndexContract.cs | 105 +++++++++++++ test/Wiki.Core.Tests/ParsingTests.cs | 145 ++++++++++++++++++ 18 files changed, 1119 insertions(+) create mode 100644 Directory.Build.props create mode 100644 src/Wiki.Core/Model/Answer.cs create mode 100644 src/Wiki.Core/Model/Document.cs create mode 100644 src/Wiki.Core/Model/Health.cs create mode 100644 src/Wiki.Core/Model/Judgement.cs create mode 100644 src/Wiki.Core/Model/RawSource.cs create mode 100644 src/Wiki.Core/Model/Retrieval.cs create mode 100644 src/Wiki.Core/Parsing/DocumentParser.cs create mode 100644 src/Wiki.Core/Parsing/Frontmatter.cs create mode 100644 src/Wiki.Core/Parsing/ParagraphChunker.cs create mode 100644 src/Wiki.Core/Parsing/WikiLinks.cs create mode 100644 src/Wiki.Core/Ports/Ports.cs create mode 100644 test/Wiki.Contracts/DocumentCatalogContract.cs create mode 100644 test/Wiki.Contracts/EmbeddingModelContract.cs create mode 100644 test/Wiki.Contracts/NoteStoreContract.cs create mode 100644 test/Wiki.Contracts/VaultFixture.cs create mode 100644 test/Wiki.Contracts/VectorIndexContract.cs create mode 100644 test/Wiki.Core.Tests/ParsingTests.cs diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..9bdab74 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + true + latest + true + + diff --git a/src/Wiki.Core/Model/Answer.cs b/src/Wiki.Core/Model/Answer.cs new file mode 100644 index 0000000..0021c4b --- /dev/null +++ b/src/Wiki.Core/Model/Answer.cs @@ -0,0 +1,20 @@ +namespace Wiki.Core.Model; + +/// A paragraph handed to the answer model as evidence, with the judge's probability that it answers. +public sealed record EvidenceParagraph(Paragraph Paragraph, string NoteTitle, double Probability); + +/// Everything the answer model is allowed to see. Not the whole context: the selected evidence and the judgements. +public sealed record AnswerRequest( + string Question, + IReadOnlyList Evidence, + IReadOnlyDictionary Judgements, + double Covered); + +public abstract record AnswerChunk +{ + /// A piece of the answer text, in order. + public sealed record Text(string Delta) : AnswerChunk; + + /// The end of the answer, with what it cost. + public sealed record Done(string ModelId, long InputTokens, long OutputTokens, TimeSpan Elapsed) : AnswerChunk; +} diff --git a/src/Wiki.Core/Model/Document.cs b/src/Wiki.Core/Model/Document.cs new file mode 100644 index 0000000..0ae71d6 --- /dev/null +++ b/src/Wiki.Core/Model/Document.cs @@ -0,0 +1,76 @@ +namespace Wiki.Core.Model; + +/// Where in the vault a document lives. Kinds are disjoint sets of paths. +public enum DocumentKind +{ + /// wiki/*.md — the retrieval corpus, embedded. + Wiki, + /// raw/*.md — sources waiting for distillation. + Raw, + /// raw/_processed/*.md — sources distillation has finished with; provenance. + RawProcessed, + /// output/*.md — synthesised answers, audit and consolidation reports. + Output, +} + +/// A listing entry: enough to know whether a document changed without reading it. +public sealed record DocumentRef(DocumentKind Kind, string Slug, string Path, string ContentHash); + +/// A parsed vault document. Frontmatter fields the vault uses are lifted into properties; the rest stay in . +public sealed record Document( + DocumentKind Kind, + string Slug, + string Path, + string ContentHash, + string Title, + string? Type, + IReadOnlyList Tags, + IReadOnlyList Aliases, + IReadOnlyList Related, + IReadOnlyList Sources, + DateOnly? Updated, + IReadOnlyDictionary Frontmatter, + string Body, + int BodyStartLine) +{ + public DocumentRef Ref => new(Kind, Slug, Path, ContentHash); +} + +/// A frontmatter value: a scalar or a list of scalars. The vault uses nothing deeper. +public sealed record FrontmatterValue(string? Scalar, IReadOnlyList? List) +{ + public static FrontmatterValue Of(string scalar) => new(scalar, null); + public static FrontmatterValue Of(IReadOnlyList list) => new(null, list); + public bool IsList => List is not null; + + /// The value as a list: a list as is, a scalar as a one-element list, an empty scalar as empty. + public IReadOnlyList AsList() => + List ?? (string.IsNullOrWhiteSpace(Scalar) ? [] : [Scalar!]); +} + +/// Part of a document body addressable in typed questions: slug#pN. +public sealed record Paragraph(string Id, string Slug, string Heading, string Text); + +/// An outgoing reference from one document to another, by target slug. +public sealed record Link(string FromSlug, string ToSlug, LinkKind Kind, int? Line); + +public enum LinkKind +{ + /// [[target]] in the body. + WikiLink, + /// An entry in the related: frontmatter list. + Related, +} + +/// A caller identity for attribution. Resolved by the host's authentication, carried into use cases. +public sealed record Identity(string Name, IdentityKind Kind) +{ + public static readonly Identity Anonymous = new("anonymous", IdentityKind.Anonymous); +} + +public enum IdentityKind +{ + Anonymous, + Human, + Machine, +} diff --git a/src/Wiki.Core/Model/Health.cs b/src/Wiki.Core/Model/Health.cs new file mode 100644 index 0000000..7219fa9 --- /dev/null +++ b/src/Wiki.Core/Model/Health.cs @@ -0,0 +1,4 @@ +namespace Wiki.Core.Model; + +/// Result of an adapter's self check. Reported in status, never used for readiness. +public sealed record AdapterHealth(string Adapter, bool Ok, string? Detail, TimeSpan Latency, DateTimeOffset CheckedAt); diff --git a/src/Wiki.Core/Model/Judgement.cs b/src/Wiki.Core/Model/Judgement.cs new file mode 100644 index 0000000..5d5fea9 --- /dev/null +++ b/src/Wiki.Core/Model/Judgement.cs @@ -0,0 +1,36 @@ +namespace Wiki.Core.Model; + +/// +/// A typed question over a state. Three primitives, as TypeSafe's System One defines them; +/// any judge adapter has to answer all three. +/// +public abstract record TypedQuestion(string Instructions) +{ + /// A yes/no judgement with a probability. Criteria describe both outcomes. + public sealed record Noul(string Instructions, string TrueCriterion, string FalseCriterion) : TypedQuestion(Instructions); + + /// One option out of many, with a probability per option. Cap is the judge's concern. + public sealed record Choice(string Instructions, IReadOnlyDictionary Options) : TypedQuestion(Instructions); + + /// A graded judgement in [0, 1]. Criteria describe the ends of the scale. + public sealed record Score(string Instructions, string Criteria) : TypedQuestion(Instructions); +} + +/// The state a judge sees plus the questions asked about it, one request. +public sealed record JudgeRequest(object State, IReadOnlyDictionary Questions); + +public abstract record Judgement +{ + public sealed record Noul(double Probability) : Judgement; + public sealed record Choice(string Selected, IReadOnlyDictionary Probabilities, double Confidence) : Judgement; + public sealed record Score(double Value) : Judgement; +} + +public sealed record JudgeUsage(int Requests, long InputTokens, long OutputTokens, TimeSpan Elapsed) +{ + public static readonly JudgeUsage None = new(0, 0, 0, TimeSpan.Zero); + public JudgeUsage Add(JudgeUsage other) => + new(Requests + other.Requests, InputTokens + other.InputTokens, OutputTokens + other.OutputTokens, Elapsed + other.Elapsed); +} + +public sealed record JudgeResult(IReadOnlyDictionary Answers, JudgeUsage Usage); diff --git a/src/Wiki.Core/Model/RawSource.cs b/src/Wiki.Core/Model/RawSource.cs new file mode 100644 index 0000000..c72141f --- /dev/null +++ b/src/Wiki.Core/Model/RawSource.cs @@ -0,0 +1,29 @@ +namespace Wiki.Core.Model; + +/// +/// A fact an agent deposits. Becomes one file in raw/ with provenance frontmatter. +/// There is deliberately no field for a hypothesis: the shape enforces the raw rule. +/// +public sealed record RawSource( + string Title, + string Source, + string Content, + DateTimeOffset ObservedAt, + IReadOnlyList Tags) +{ + public const int MinimumContentLength = 40; + + /// Validation errors, empty when the fact may be stored. + public IReadOnlyList Validate() + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(Title)) errors.Add("title is required"); + if (string.IsNullOrWhiteSpace(Source)) errors.Add("source is required: where the fact was observed or quoted from"); + if (Content is null || Content.Trim().Length < MinimumContentLength) + errors.Add($"content must be at least {MinimumContentLength} characters of observation"); + return errors; + } +} + +/// What the fact sink returns once the source is stored. +public sealed record FactReceipt(string Path, string Commit, DateTimeOffset StoredAt); diff --git a/src/Wiki.Core/Model/Retrieval.cs b/src/Wiki.Core/Model/Retrieval.cs new file mode 100644 index 0000000..ff61934 --- /dev/null +++ b/src/Wiki.Core/Model/Retrieval.cs @@ -0,0 +1,16 @@ +namespace Wiki.Core.Model; + +/// A vector for one document under one embedding model. +public sealed record Embedding(string ModelId, string Slug, string ContentHash, float[] Vector); + +/// One result of a vector query. +public sealed record RankedHit(string Slug, float Score); + +/// What an index holds for one model: which documents at which content hash. +public sealed record IndexState(string ModelId, IReadOnlyDictionary ContentHashBySlug) +{ + public int Count => ContentHashBySlug.Count; +} + +/// Head of the note store: the commit the listing is taken from. +public sealed record StoreHead(string Commit, DateTimeOffset? CommittedAt); diff --git a/src/Wiki.Core/Parsing/DocumentParser.cs b/src/Wiki.Core/Parsing/DocumentParser.cs new file mode 100644 index 0000000..4c8db7e --- /dev/null +++ b/src/Wiki.Core/Parsing/DocumentParser.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using Wiki.Core.Model; + +namespace Wiki.Core.Parsing; + +/// Turns a vault file into a . Kind and slug come from the path. +public static class DocumentParser +{ + public static Document Parse(DocumentRef reference, string text) + { + var (fields, body, bodyStart) = Frontmatter.Parse(text); + var title = Field(fields, "title")?.Scalar?.Trim(); + if (string.IsNullOrEmpty(title)) title = reference.Slug; + return new Document( + reference.Kind, + reference.Slug, + reference.Path, + reference.ContentHash, + title, + Field(fields, "type")?.Scalar?.Trim(), + ListField(fields, "tags"), + ListField(fields, "aliases"), + ListField(fields, "related"), + ListField(fields, "sources"), + ParseDate(Field(fields, "updated")?.Scalar), + fields, + body, + bodyStart); + } + + /// Kind and slug for a repository path, or null when the path is not a vault document. + public static (DocumentKind Kind, string Slug)? Classify(string path) + { + if (!path.EndsWith(".md", StringComparison.Ordinal)) return null; + var slash = path.LastIndexOf('/'); + var dir = slash < 0 ? string.Empty : path[..(slash + 1)]; + var name = path[(slash + 1)..^3]; + if (name.Length == 0 || name.StartsWith('_')) return null; // _index.md and friends + return dir switch + { + "wiki/" => (DocumentKind.Wiki, name), + "raw/" => (DocumentKind.Raw, name), + "raw/_processed/" => (DocumentKind.RawProcessed, name), + "output/" => (DocumentKind.Output, name), + _ => null, + }; + } + + public static string PathFor(DocumentKind kind, string slug) => kind switch + { + DocumentKind.Wiki => $"wiki/{slug}.md", + DocumentKind.Raw => $"raw/{slug}.md", + DocumentKind.RawProcessed => $"raw/_processed/{slug}.md", + DocumentKind.Output => $"output/{slug}.md", + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + + private static FrontmatterValue? Field(IReadOnlyDictionary fields, string key) => + fields.TryGetValue(key, out var v) ? v : null; + + private static IReadOnlyList ListField(IReadOnlyDictionary fields, string key) => + Field(fields, key)?.AsList() ?? []; + + public static DateOnly? ParseDate(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var s = value.Trim(); + if (s.Length >= 10 && DateOnly.TryParseExact(s[..10], "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var d)) + return d; + return null; + } +} diff --git a/src/Wiki.Core/Parsing/Frontmatter.cs b/src/Wiki.Core/Parsing/Frontmatter.cs new file mode 100644 index 0000000..4a16b9e --- /dev/null +++ b/src/Wiki.Core/Parsing/Frontmatter.cs @@ -0,0 +1,134 @@ +using System.Text; +using Wiki.Core.Model; + +namespace Wiki.Core.Parsing; + +/// +/// Reads the YAML subset the vault uses: key: scalar, key: [a, "b, c", 'd'], +/// block lists of - item, quoted scalars, and nothing nested deeper. Tolerant: a line +/// it cannot read is skipped, never fatal, because a note with odd frontmatter is still a note. +/// +public static class Frontmatter +{ + public static (IReadOnlyDictionary Fields, string Body, int BodyStartLine) Parse(string text) + { + var fields = new Dictionary(StringComparer.Ordinal); + if (!text.StartsWith("---", StringComparison.Ordinal)) + return (fields, text, 1); + + var lines = text.Split('\n'); + if (lines[0].TrimEnd('\r').Trim() != "---") + return (fields, text, 1); + + var end = -1; + for (var i = 1; i < lines.Length; i++) + { + if (lines[i].TrimEnd('\r').Trim() == "---") { end = i; break; } + } + if (end < 0) + return (fields, text, 1); + + string? pendingKey = null; + List? pendingList = null; + for (var i = 1; i < end; i++) + { + var line = lines[i].TrimEnd('\r'); + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) + continue; + + if (pendingKey is not null && line.TrimStart().StartsWith("- ", StringComparison.Ordinal)) + { + pendingList ??= []; + pendingList.Add(Unquote(line.TrimStart()[2..].Trim())); + continue; + } + if (pendingKey is not null) + { + fields[pendingKey] = pendingList is null ? FrontmatterValue.Of(string.Empty) : FrontmatterValue.Of(pendingList); + pendingKey = null; + pendingList = null; + } + + var colon = IndexOfKeyColon(line); + if (colon < 0) + continue; + var key = line[..colon].Trim(); + var value = line[(colon + 1)..].Trim(); + if (key.Length == 0) + continue; + + if (value.Length == 0) + { + pendingKey = key; // a block list may follow; otherwise an empty scalar + continue; + } + if (value.StartsWith('[') && value.EndsWith(']')) + { + fields[key] = FrontmatterValue.Of(SplitFlowList(value[1..^1])); + continue; + } + fields[key] = FrontmatterValue.Of(Unquote(value)); + } + if (pendingKey is not null) + fields[pendingKey] = pendingList is null ? FrontmatterValue.Of(string.Empty) : FrontmatterValue.Of(pendingList); + + var body = string.Join('\n', lines.Skip(end + 1)); + return (fields, body, end + 2); + } + + private static int IndexOfKeyColon(string line) + { + // A key is [A-Za-z_][\w-]* at column 0 followed by ':' and a space or end of line. + var i = 0; + if (i >= line.Length || !(char.IsLetter(line[i]) || line[i] == '_')) return -1; + while (i < line.Length && (char.IsLetterOrDigit(line[i]) || line[i] == '_' || line[i] == '-')) i++; + if (i >= line.Length || line[i] != ':') return -1; + if (i + 1 < line.Length && line[i + 1] != ' ' && line[i + 1] != '\t') return -1; + return i; + } + + /// Splits a, "b, c", 'd' respecting quotes; sources lists carry commas inside quotes. + public static IReadOnlyList SplitFlowList(string inner) + { + var items = new List(); + var current = new StringBuilder(); + char? quote = null; + foreach (var ch in inner) + { + if (quote is null && (ch == '"' || ch == '\'')) + { + quote = ch; + current.Append(ch); + continue; + } + if (quote is not null && ch == quote) + { + quote = null; + current.Append(ch); + continue; + } + if (quote is null && ch == ',') + { + Push(items, current); + continue; + } + current.Append(ch); + } + Push(items, current); + return items; + + static void Push(List items, StringBuilder sb) + { + var s = Unquote(sb.ToString().Trim()); + sb.Clear(); + if (s.Length > 0) items.Add(s); + } + } + + public static string Unquote(string value) + { + if (value.Length >= 2 && ((value[0] == '"' && value[^1] == '"') || (value[0] == '\'' && value[^1] == '\''))) + return value[1..^1]; + return value; + } +} diff --git a/src/Wiki.Core/Parsing/ParagraphChunker.cs b/src/Wiki.Core/Parsing/ParagraphChunker.cs new file mode 100644 index 0000000..8e90b2d --- /dev/null +++ b/src/Wiki.Core/Parsing/ParagraphChunker.cs @@ -0,0 +1,63 @@ +using System.Text; +using Wiki.Core.Model; +using Wiki.Core.Ports; + +namespace Wiki.Core.Parsing; + +/// +/// The vault's paragraph splitter, as wiki_ask.py does it: blank-line separated blocks, +/// headings set the block's heading and are not blocks themselves, fenced code is one block. +/// Ids are slug#pN, counted from 1 in document order. +/// +public sealed class ParagraphChunker : IChunker +{ + public IReadOnlyList Split(Document document) + { + var paragraphs = new List(); + var heading = document.Title; + var block = new StringBuilder(); + var fenced = false; + + void Flush() + { + var text = block.ToString().Trim(); + block.Clear(); + if (text.Length == 0) return; + var n = paragraphs.Count + 1; + paragraphs.Add(new Paragraph($"{document.Slug}#p{n}", document.Slug, heading, text)); + } + + foreach (var raw in document.Body.Split('\n')) + { + var line = raw.TrimEnd('\r'); + var trimmed = line.TrimStart(); + if (trimmed.StartsWith("```", StringComparison.Ordinal) || trimmed.StartsWith("~~~", StringComparison.Ordinal)) + { + if (!fenced) Flush(); + fenced = !fenced; + block.Append(line).Append('\n'); + if (!fenced) Flush(); + continue; + } + if (fenced) + { + block.Append(line).Append('\n'); + continue; + } + if (trimmed.StartsWith('#')) + { + Flush(); + heading = trimmed.TrimStart('#').Trim(); + continue; + } + if (string.IsNullOrWhiteSpace(line)) + { + Flush(); + continue; + } + block.Append(line).Append('\n'); + } + Flush(); + return paragraphs; + } +} diff --git a/src/Wiki.Core/Parsing/WikiLinks.cs b/src/Wiki.Core/Parsing/WikiLinks.cs new file mode 100644 index 0000000..5d514fb --- /dev/null +++ b/src/Wiki.Core/Parsing/WikiLinks.cs @@ -0,0 +1,52 @@ +using System.Text.RegularExpressions; +using Wiki.Core.Model; + +namespace Wiki.Core.Parsing; + +/// Outgoing references of a document: [[links]] in the body outside code, plus the related list. +public static partial class WikiLinks +{ + [GeneratedRegex(@"\[\[([^\]\[|#]+)(?:#[^\]\[|]*)?(?:\|[^\]\[]*)?\]\]")] + private static partial Regex LinkPattern(); + + [GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9._-]*$")] + private static partial Regex SlugPattern(); + + [GeneratedRegex("`[^`]*`")] + private static partial Regex InlineCode(); + + public static bool IsSlug(string value) => SlugPattern().IsMatch(value); + + public static IReadOnlyList Extract(Document document) + { + var links = new List(); + foreach (var target in document.Related) + { + if (SlugPattern().IsMatch(target)) + links.Add(new Link(document.Slug, target, LinkKind.Related, null)); + } + + var fenced = false; + var lineNo = document.BodyStartLine - 1; + foreach (var raw in document.Body.Split('\n')) + { + lineNo++; + var line = raw.TrimEnd('\r'); + var trimmed = line.TrimStart(); + if (trimmed.StartsWith("```", StringComparison.Ordinal) || trimmed.StartsWith("~~~", StringComparison.Ordinal)) + { + fenced = !fenced; + continue; + } + if (fenced) continue; + var stripped = InlineCode().Replace(line, string.Empty); + foreach (Match m in LinkPattern().Matches(stripped)) + { + var target = m.Groups[1].Value.Trim(); + if (SlugPattern().IsMatch(target)) + links.Add(new Link(document.Slug, target, LinkKind.WikiLink, lineNo)); + } + } + return links; + } +} diff --git a/src/Wiki.Core/Ports/Ports.cs b/src/Wiki.Core/Ports/Ports.cs new file mode 100644 index 0000000..1c0a039 --- /dev/null +++ b/src/Wiki.Core/Ports/Ports.cs @@ -0,0 +1,90 @@ +using Wiki.Core.Model; + +namespace Wiki.Core.Ports; + +/// Anything that can say whether it is reachable and working. Every adapter implements it. +public interface IAdapterHealth +{ + string Name { get; } + Task CheckAsync(CancellationToken ct); +} + +/// Where the vault's documents are read from. +public interface INoteStore : IAdapterHealth +{ + /// The current head; a listing is always taken at a head. + Task GetHeadAsync(CancellationToken ct); + + /// Every document of the given kinds at that head, with a content hash that changes iff the content changes. + Task> ListAsync(string commit, IReadOnlySet kinds, CancellationToken ct); + + /// The document's text at that head. + Task ReadAsync(DocumentRef reference, string commit, CancellationToken ct); +} + +/// Where a deposited fact goes: one new raw source, never an edit of an existing file. +public interface IFactSink : IAdapterHealth +{ + Task AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct); +} + +/// Text to vector. One instance per configured model. +public interface IEmbeddingModel : IAdapterHealth +{ + string ModelId { get; } + int Dimension { get; } + Task> EmbedAsync(IReadOnlyList texts, CancellationToken ct); +} + +/// Vectors per (model, document) with top-k query. Models are namespaces; nothing is shared across them. +public interface IVectorIndex : IAdapterHealth +{ + Task DescribeAsync(string modelId, CancellationToken ct); + Task UpsertAsync(IReadOnlyList embeddings, CancellationToken ct); + Task RemoveAsync(string modelId, IReadOnlyList slugs, CancellationToken ct); + Task> QueryAsync(string modelId, float[] query, int k, CancellationToken ct); + /// Persist whatever the adapter keeps in memory. A no-op for adapters that write through. + Task FlushAsync(CancellationToken ct); +} + +/// The synced documents, queryable: bodies, frontmatter and the link graph. Filled by the sync, read by everything else. +public interface IDocumentCatalog : IAdapterHealth +{ + Task GetStateAsync(CancellationToken ct); + Task SetStateAsync(CatalogState state, CancellationToken ct); + Task UpsertAsync(IReadOnlyList documents, CancellationToken ct); + Task RemoveAsync(IReadOnlyList references, CancellationToken ct); + Task GetAsync(DocumentKind kind, string slug, CancellationToken ct); + Task> ListAsync(DocumentKind kind, CancellationToken ct); + Task> ListDocumentsAsync(DocumentKind kind, CancellationToken ct); + /// Wiki documents that reference the slug, by wikilink or related entry. + Task> BacklinksAsync(string slug, CancellationToken ct); + Task> OutgoingLinksAsync(string slug, CancellationToken ct); + Task> TagCountsAsync(CancellationToken ct); + Task FlushAsync(CancellationToken ct); +} + +/// Which head the catalog reflects and when it got there. +public sealed record CatalogState(string? Commit, DateTimeOffset? SyncedAt); + +/// Splits a document body into addressable paragraphs for typed questions. +public interface IChunker +{ + IReadOnlyList Split(Document document); +} + +/// Typed questions over a state. Returns probabilities, never prose. +public interface IJudge : IAdapterHealth +{ + string ModelId { get; } + /// The largest number of options one Choice may carry, or null for no cap. + int? MaxChoiceOptions { get; } + Task JudgeAsync(JudgeRequest request, CancellationToken ct); +} + +/// Prose from a question and its evidence. Sees only what the request carries. +public interface IAnswerModel : IAdapterHealth +{ + string ModelId { get; } + IAsyncEnumerable AnswerAsync(AnswerRequest request, CancellationToken ct); +} diff --git a/test/Wiki.Contracts/DocumentCatalogContract.cs b/test/Wiki.Contracts/DocumentCatalogContract.cs new file mode 100644 index 0000000..d81bd30 --- /dev/null +++ b/test/Wiki.Contracts/DocumentCatalogContract.cs @@ -0,0 +1,112 @@ +using Wiki.Core.Model; +using Wiki.Core.Parsing; +using Wiki.Core.Ports; + +namespace Wiki.Contracts; + +/// Every adapter derives from this. +public abstract class DocumentCatalogContract +{ + protected abstract Task CreateCatalogAsync(); + protected abstract Task ReopenAsync(IDocumentCatalog catalog); + + private static Document Doc(string path, string hash = "h") => + DocumentParser.Parse(Ref(path, hash), VaultFixture.Files[path]); + + private static DocumentRef Ref(string path, string hash = "h") + { + var (kind, slug) = DocumentParser.Classify(path)!.Value; + return new DocumentRef(kind, slug, path, hash); + } + + [Fact] + public async Task StartsEmptyWithNoState() + { + var catalog = await CreateCatalogAsync(); + var state = await catalog.GetStateAsync(CancellationToken.None); + Assert.Null(state.Commit); + Assert.Empty(await catalog.ListAsync(DocumentKind.Wiki, CancellationToken.None)); + } + + [Fact] + public async Task UpsertGetListByKind() + { + var catalog = await CreateCatalogAsync(); + await catalog.UpsertAsync([Doc("wiki/udm-dns.md"), Doc("wiki/ceph-osd.md"), Doc("raw/2026-09-23-pending.md"), Doc("output/2026-09-20-answer.md")], CancellationToken.None); + + var got = await catalog.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None); + Assert.NotNull(got); + Assert.Equal("UDM intercepts DNS", got.Title); + Assert.Contains("answers every UDP/53", got.Body); + Assert.Null(await catalog.GetAsync(DocumentKind.Raw, "udm-dns", CancellationToken.None)); + + Assert.Equal(2, (await catalog.ListAsync(DocumentKind.Wiki, CancellationToken.None)).Count); + Assert.Single(await catalog.ListAsync(DocumentKind.Raw, CancellationToken.None)); + Assert.Single(await catalog.ListDocumentsAsync(DocumentKind.Output, CancellationToken.None)); + } + + [Fact] + public async Task LinksAndBacklinksFollowWikilinksAndRelated() + { + var catalog = await CreateCatalogAsync(); + await catalog.UpsertAsync([Doc("wiki/udm-dns.md"), Doc("wiki/ceph-osd.md")], CancellationToken.None); + var outgoing = await catalog.OutgoingLinksAsync("udm-dns", CancellationToken.None); + Assert.Equal([LinkKind.Related, LinkKind.WikiLink], outgoing.Select(l => l.Kind)); + Assert.All(outgoing, l => Assert.Equal("ceph-osd", l.ToSlug)); + var back = await catalog.BacklinksAsync("ceph-osd", CancellationToken.None); + Assert.Equal(2, back.Count); + Assert.All(back, l => Assert.Equal("udm-dns", l.FromSlug)); + Assert.Empty(await catalog.BacklinksAsync("udm-dns", CancellationToken.None)); + } + + [Fact] + public async Task RemoveDropsDocumentAndItsLinks() + { + var catalog = await CreateCatalogAsync(); + await catalog.UpsertAsync([Doc("wiki/udm-dns.md"), Doc("wiki/ceph-osd.md")], CancellationToken.None); + await catalog.RemoveAsync([Ref("wiki/udm-dns.md")], CancellationToken.None); + Assert.Null(await catalog.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None)); + Assert.Empty(await catalog.BacklinksAsync("ceph-osd", CancellationToken.None)); + } + + [Fact] + public async Task UpsertReplacesLinks() + { + var catalog = await CreateCatalogAsync(); + await catalog.UpsertAsync([Doc("wiki/udm-dns.md")], CancellationToken.None); + var changed = Doc("wiki/udm-dns.md", "h2") with { Body = "no links any more", Related = [] }; + await catalog.UpsertAsync([changed], CancellationToken.None); + Assert.Empty(await catalog.OutgoingLinksAsync("udm-dns", CancellationToken.None)); + Assert.Equal("h2", (await catalog.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None))!.ContentHash); + } + + [Fact] + public async Task TagCountsCoverWikiOnly() + { + var catalog = await CreateCatalogAsync(); + await catalog.UpsertAsync([Doc("wiki/udm-dns.md"), Doc("wiki/ceph-osd.md"), Doc("output/2026-09-20-answer.md")], CancellationToken.None); + var tags = await catalog.TagCountsAsync(CancellationToken.None); + Assert.Equal(1, tags["dns"]); + Assert.Equal(1, tags["udm"]); + Assert.Equal(1, tags["ceph"]); + Assert.Equal(3, tags.Count); + } + + [Fact] + public async Task StateAndDocumentsSurviveFlushAndReopen() + { + var catalog = await CreateCatalogAsync(); + await catalog.UpsertAsync([Doc("wiki/udm-dns.md"), Doc("wiki/ceph-osd.md")], CancellationToken.None); + var at = new DateTimeOffset(2026, 9, 24, 10, 0, 0, TimeSpan.Zero); + await catalog.SetStateAsync(new CatalogState(VaultFixture.Commit, at), CancellationToken.None); + await catalog.FlushAsync(CancellationToken.None); + + var reopened = await ReopenAsync(catalog); + var state = await reopened.GetStateAsync(CancellationToken.None); + Assert.Equal(VaultFixture.Commit, state.Commit); + Assert.Equal(at, state.SyncedAt); + Assert.Equal(2, (await reopened.ListAsync(DocumentKind.Wiki, CancellationToken.None)).Count); + Assert.Equal(2, (await reopened.BacklinksAsync("ceph-osd", CancellationToken.None)).Count); + Assert.Equal("UDM intercepts DNS", (await reopened.GetAsync(DocumentKind.Wiki, "udm-dns", CancellationToken.None))!.Title); + } +} diff --git a/test/Wiki.Contracts/EmbeddingModelContract.cs b/test/Wiki.Contracts/EmbeddingModelContract.cs new file mode 100644 index 0000000..ff47987 --- /dev/null +++ b/test/Wiki.Contracts/EmbeddingModelContract.cs @@ -0,0 +1,49 @@ +using Wiki.Core.Ports; + +namespace Wiki.Contracts; + +/// Every adapter derives from this. The adapter under test may talk to a fake server. +public abstract class EmbeddingModelContract +{ + protected abstract Task CreateModelAsync(); + + [Fact] + public async Task VectorsHaveTheDeclaredDimensionAndUnitLength() + { + var model = await CreateModelAsync(); + var vectors = await model.EmbedAsync(["alpha", "beta"], CancellationToken.None); + Assert.Equal(2, vectors.Count); + foreach (var v in vectors) + { + Assert.Equal(model.Dimension, v.Length); + var norm = MathF.Sqrt(v.Sum(x => x * x)); + Assert.InRange(norm, 0.99f, 1.01f); + } + } + + [Fact] + public async Task SameTextSameVectorAndOrderPreserved() + { + var model = await CreateModelAsync(); + var once = await model.EmbedAsync(["one", "two", "three"], CancellationToken.None); + var again = await model.EmbedAsync(["three", "one"], CancellationToken.None); + Assert.Equal(once[2], again[0]); + Assert.Equal(once[0], again[1]); + } + + [Fact] + public async Task EmptyBatchIsEmpty() + { + var model = await CreateModelAsync(); + Assert.Empty(await model.EmbedAsync([], CancellationToken.None)); + } + + [Fact] + public async Task ModelIdIsSet() + { + var model = await CreateModelAsync(); + Assert.False(string.IsNullOrWhiteSpace(model.ModelId)); + var health = await model.CheckAsync(CancellationToken.None); + Assert.True(health.Ok, health.Detail); + } +} diff --git a/test/Wiki.Contracts/NoteStoreContract.cs b/test/Wiki.Contracts/NoteStoreContract.cs new file mode 100644 index 0000000..86fc37d --- /dev/null +++ b/test/Wiki.Contracts/NoteStoreContract.cs @@ -0,0 +1,68 @@ +using Wiki.Core.Model; +using Wiki.Core.Ports; + +namespace Wiki.Contracts; + +/// Every adapter derives a test class from this and supplies a store over . +public abstract class NoteStoreContract +{ + /// A store whose current head exposes exactly . + protected abstract Task CreateStoreAsync(); + + [Fact] + public async Task HeadHasACommit() + { + var store = await CreateStoreAsync(); + var head = await store.GetHeadAsync(CancellationToken.None); + Assert.False(string.IsNullOrWhiteSpace(head.Commit)); + } + + [Fact] + public async Task ListsOnlyVaultDocumentsOfRequestedKinds() + { + var store = await CreateStoreAsync(); + var head = await store.GetHeadAsync(CancellationToken.None); + var all = await store.ListAsync(head.Commit, VaultFixture.AllKinds, CancellationToken.None); + Assert.Equal( + VaultFixture.Expected.OrderBy(e => e.Path), + all.Select(r => (r.Kind, r.Slug, r.Path)).OrderBy(e => e.Path)); + + var wiki = await store.ListAsync(head.Commit, new HashSet { DocumentKind.Wiki }, CancellationToken.None); + Assert.All(wiki, r => Assert.Equal(DocumentKind.Wiki, r.Kind)); + Assert.Equal(2, wiki.Count); + } + + [Fact] + public async Task ContentHashIsStableAndDistinct() + { + var store = await CreateStoreAsync(); + var head = await store.GetHeadAsync(CancellationToken.None); + var a = await store.ListAsync(head.Commit, VaultFixture.AllKinds, CancellationToken.None); + var b = await store.ListAsync(head.Commit, VaultFixture.AllKinds, CancellationToken.None); + Assert.Equal(a.OrderBy(r => r.Path).Select(r => r.ContentHash), b.OrderBy(r => r.Path).Select(r => r.ContentHash)); + Assert.All(a, r => Assert.False(string.IsNullOrWhiteSpace(r.ContentHash))); + Assert.Equal(a.Count, a.Select(r => r.ContentHash).Distinct().Count()); + } + + [Fact] + public async Task ReadsExactText() + { + var store = await CreateStoreAsync(); + var head = await store.GetHeadAsync(CancellationToken.None); + var refs = await store.ListAsync(head.Commit, VaultFixture.AllKinds, CancellationToken.None); + foreach (var r in refs) + { + var text = await store.ReadAsync(r, head.Commit, CancellationToken.None); + Assert.Equal(VaultFixture.Files[r.Path], text); + } + } + + [Fact] + public async Task HealthReportsOk() + { + var store = await CreateStoreAsync(); + var health = await store.CheckAsync(CancellationToken.None); + Assert.True(health.Ok, health.Detail); + Assert.Equal(store.Name, health.Adapter); + } +} diff --git a/test/Wiki.Contracts/VaultFixture.cs b/test/Wiki.Contracts/VaultFixture.cs new file mode 100644 index 0000000..acec096 --- /dev/null +++ b/test/Wiki.Contracts/VaultFixture.cs @@ -0,0 +1,38 @@ +using Wiki.Core.Model; + +namespace Wiki.Contracts; + +/// +/// A tiny vault every note-store adapter has to expose identically. Adapters under test are +/// pointed at a fake or a temp copy holding exactly these files; the contract checks that the +/// listing and reads come back as written. +/// +public static class VaultFixture +{ + public const string Commit = "0123456789abcdef0123456789abcdef01234567"; + + public static readonly IReadOnlyDictionary Files = new Dictionary + { + ["wiki/_index.md"] = "---\ntitle: index\ntype: index\n---\n", + ["wiki/udm-dns.md"] = "---\ntitle: UDM intercepts DNS\ntype: wiki\ntags: [dns, udm]\nsources: [raw/_processed/2026-09-01-dns.md]\nrelated: [ceph-osd]\nupdated: 2026-09-16\n---\n\n# UDM intercepts DNS\n\nThe UDM answers every UDP/53 query itself. See [[ceph-osd]].\n", + ["wiki/ceph-osd.md"] = "---\ntitle: Ceph OSD\ntype: wiki\ntags: [ceph]\nsources: [raw/_processed/2026-09-01-dns.md]\nupdated: 2026-09-10\n---\n\n# Ceph OSD\n\nOSD fsid mismatch after rebuild.\n", + ["raw/2026-09-23-pending.md"] = "---\ntitle: Pending source\ntype: raw\nsource: test\ncaptured: 2026-09-23\n---\n\nVerbatim capture.\n", + ["raw/_processed/2026-09-01-dns.md"] = "---\ntitle: Processed source\ntype: raw\nsource: test\ncaptured: 2026-09-01\n---\n\nVerbatim capture, processed.\n", + ["output/2026-09-20-answer.md"] = "---\ntitle: An answer\ntype: output\nquestion: Does the UDM intercept DNS?\nsources: [udm-dns]\n---\n\nYes.\n", + ["bin/wiki_rank.py"] = "#!/usr/bin/env python3\n", + ["Untitled.md"] = "not a vault document\n", + }; + + /// The refs a listing over all kinds must return (order not significant). + public static readonly IReadOnlyList<(DocumentKind Kind, string Slug, string Path)> Expected = + [ + (DocumentKind.Wiki, "udm-dns", "wiki/udm-dns.md"), + (DocumentKind.Wiki, "ceph-osd", "wiki/ceph-osd.md"), + (DocumentKind.Raw, "2026-09-23-pending", "raw/2026-09-23-pending.md"), + (DocumentKind.RawProcessed, "2026-09-01-dns", "raw/_processed/2026-09-01-dns.md"), + (DocumentKind.Output, "2026-09-20-answer", "output/2026-09-20-answer.md"), + ]; + + public static readonly IReadOnlySet AllKinds = + new HashSet { DocumentKind.Wiki, DocumentKind.Raw, DocumentKind.RawProcessed, DocumentKind.Output }; +} diff --git a/test/Wiki.Contracts/VectorIndexContract.cs b/test/Wiki.Contracts/VectorIndexContract.cs new file mode 100644 index 0000000..b9cd218 --- /dev/null +++ b/test/Wiki.Contracts/VectorIndexContract.cs @@ -0,0 +1,105 @@ +using Wiki.Core.Model; +using Wiki.Core.Ports; + +namespace Wiki.Contracts; + +/// Every adapter derives from this. Vectors are unit length; scores are cosine. +public abstract class VectorIndexContract +{ + /// A fresh, empty index. Called again after semantics are needed. + protected abstract Task CreateIndexAsync(); + + /// The same index reopened from persisted state, or the same instance for adapters that keep no files. + protected abstract Task ReopenAsync(IVectorIndex index); + + private const string Model = "test-model"; + + private static float[] Unit(params float[] v) + { + var n = MathF.Sqrt(v.Sum(x => x * x)); + return v.Select(x => x / n).ToArray(); + } + + [Fact] + public async Task EmptyIndexDescribesEmptyAndQueriesNothing() + { + var index = await CreateIndexAsync(); + var state = await index.DescribeAsync(Model, CancellationToken.None); + Assert.Equal(0, state.Count); + Assert.Empty(await index.QueryAsync(Model, Unit(1, 0, 0), 5, CancellationToken.None)); + } + + [Fact] + public async Task QueryRanksByCosineAndHonoursK() + { + var index = await CreateIndexAsync(); + await index.UpsertAsync( + [ + new Embedding(Model, "a", "h1", Unit(1, 0, 0)), + new Embedding(Model, "b", "h2", Unit(0.9f, 0.1f, 0)), + new Embedding(Model, "c", "h3", Unit(0, 1, 0)), + new Embedding(Model, "d", "h4", Unit(0, 0, 1)), + ], CancellationToken.None); + + var hits = await index.QueryAsync(Model, Unit(1, 0, 0), 2, CancellationToken.None); + Assert.Equal(["a", "b"], hits.Select(h => h.Slug)); + Assert.True(hits[0].Score > hits[1].Score); + Assert.InRange(hits[0].Score, 0.999f, 1.001f); + } + + [Fact] + public async Task UpsertReplacesAndDescribeTracksHashes() + { + var index = await CreateIndexAsync(); + await index.UpsertAsync([new Embedding(Model, "a", "h1", Unit(1, 0, 0))], CancellationToken.None); + await index.UpsertAsync([new Embedding(Model, "a", "h2", Unit(0, 1, 0))], CancellationToken.None); + var state = await index.DescribeAsync(Model, CancellationToken.None); + Assert.Equal(1, state.Count); + Assert.Equal("h2", state.ContentHashBySlug["a"]); + var hits = await index.QueryAsync(Model, Unit(0, 1, 0), 1, CancellationToken.None); + Assert.Equal("a", hits[0].Slug); + Assert.InRange(hits[0].Score, 0.999f, 1.001f); + } + + [Fact] + public async Task RemoveDropsSlugs() + { + var index = await CreateIndexAsync(); + await index.UpsertAsync([new Embedding(Model, "a", "h1", Unit(1, 0, 0)), new Embedding(Model, "b", "h2", Unit(0, 1, 0))], CancellationToken.None); + await index.RemoveAsync(Model, ["a", "missing"], CancellationToken.None); + var state = await index.DescribeAsync(Model, CancellationToken.None); + Assert.Equal(["b"], state.ContentHashBySlug.Keys); + } + + [Fact] + public async Task ModelsAreIsolatedNamespaces() + { + var index = await CreateIndexAsync(); + await index.UpsertAsync([new Embedding("m1", "a", "h1", Unit(1, 0, 0))], CancellationToken.None); + await index.UpsertAsync([new Embedding("m2", "b", "h2", Unit(1, 0, 0))], CancellationToken.None); + Assert.Equal(["a"], (await index.QueryAsync("m1", Unit(1, 0, 0), 10, CancellationToken.None)).Select(h => h.Slug)); + Assert.Equal(["b"], (await index.QueryAsync("m2", Unit(1, 0, 0), 10, CancellationToken.None)).Select(h => h.Slug)); + Assert.Equal(1, (await index.DescribeAsync("m1", CancellationToken.None)).Count); + } + + [Fact] + public async Task SurvivesFlushAndReopen() + { + var index = await CreateIndexAsync(); + await index.UpsertAsync([new Embedding(Model, "a", "h1", Unit(1, 0, 0)), new Embedding(Model, "b", "h2", Unit(0, 1, 0))], CancellationToken.None); + await index.FlushAsync(CancellationToken.None); + var reopened = await ReopenAsync(index); + var state = await reopened.DescribeAsync(Model, CancellationToken.None); + Assert.Equal(2, state.Count); + var hits = await reopened.QueryAsync(Model, Unit(0, 1, 0), 1, CancellationToken.None); + Assert.Equal("b", hits[0].Slug); + } + + [Fact] + public async Task HealthReportsOk() + { + var index = await CreateIndexAsync(); + var health = await index.CheckAsync(CancellationToken.None); + Assert.True(health.Ok, health.Detail); + } +} diff --git a/test/Wiki.Core.Tests/ParsingTests.cs b/test/Wiki.Core.Tests/ParsingTests.cs new file mode 100644 index 0000000..f88fa9a --- /dev/null +++ b/test/Wiki.Core.Tests/ParsingTests.cs @@ -0,0 +1,145 @@ +using Wiki.Core.Model; +using Wiki.Core.Parsing; + +namespace Wiki.Core.Tests; + +public class FrontmatterTests +{ + private const string Note = """ + --- + title: UDM intercepts DNS + type: wiki + tags: [dns, udm, "networking, lan"] + aliases: + - udm dns + - 'split horizon' + sources: [raw/_processed/a.md, "live check 2026-09-10: dig @1.1.1.1, dig @10.2.40.99"] + related: [ceph-osd, k8s-gateway-in-cluster-dns] + updated: 2026-09-16 + empty: + --- + + # UDM intercepts DNS + + Body line one. + """; + + [Fact] + public void ParsesScalarsFlowListsBlockListsAndQuotes() + { + var (fields, body, start) = Frontmatter.Parse(Note); + Assert.Equal("UDM intercepts DNS", fields["title"].Scalar); + Assert.Equal(["dns", "udm", "networking, lan"], fields["tags"].List!); + Assert.Equal(["udm dns", "split horizon"], fields["aliases"].List!); + Assert.Equal(["raw/_processed/a.md", "live check 2026-09-10: dig @1.1.1.1, dig @10.2.40.99"], fields["sources"].List!); + Assert.Equal(string.Empty, fields["empty"].Scalar); + Assert.Equal(13, start); + Assert.StartsWith("\n# UDM intercepts DNS", body); + } + + [Fact] + public void NoFrontmatterIsWholeBody() + { + var (fields, body, start) = Frontmatter.Parse("# Just a body\n"); + Assert.Empty(fields); + Assert.Equal("# Just a body\n", body); + Assert.Equal(1, start); + } + + [Fact] + public void UnterminatedFrontmatterIsWholeBody() + { + var (fields, body, _) = Frontmatter.Parse("---\ntitle: x\nno end"); + Assert.Empty(fields); + Assert.Equal("---\ntitle: x\nno end", body); + } +} + +public class DocumentParserTests +{ + [Fact] + public void LiftsVaultFields() + { + var reference = new DocumentRef(DocumentKind.Wiki, "udm-dns", "wiki/udm-dns.md", "abc"); + var doc = DocumentParser.Parse(reference, FrontmatterTests_Note.Text); + Assert.Equal("UDM intercepts DNS", doc.Title); + Assert.Equal("wiki", doc.Type); + Assert.Equal(new DateOnly(2026, 9, 16), doc.Updated); + Assert.Equal(["ceph-osd", "k8s-gateway-in-cluster-dns"], doc.Related); + Assert.Equal(2, doc.Sources.Count); + Assert.Equal(reference, doc.Ref); + } + + [Fact] + public void TitleFallsBackToSlug() + { + var doc = DocumentParser.Parse(new DocumentRef(DocumentKind.Raw, "x", "raw/x.md", "h"), "no frontmatter"); + Assert.Equal("x", doc.Title); + } + + [Theory] + [InlineData("wiki/a-b.md", DocumentKind.Wiki, "a-b")] + [InlineData("raw/2026-09-23-x.md", DocumentKind.Raw, "2026-09-23-x")] + [InlineData("raw/_processed/x.md", DocumentKind.RawProcessed, "x")] + [InlineData("output/2026-09-23-y.md", DocumentKind.Output, "2026-09-23-y")] + public void ClassifiesVaultPaths(string path, DocumentKind kind, string slug) + { + Assert.Equal((kind, slug), DocumentParser.Classify(path)); + Assert.Equal(path, DocumentParser.PathFor(kind, slug)); + } + + [Theory] + [InlineData("wiki/_index.md")] + [InlineData("bin/wiki_rank.py")] + [InlineData("Untitled.md")] + [InlineData("_archive/old.md")] + [InlineData("wiki/sub/x.md")] + public void IgnoresNonDocuments(string path) => Assert.Null(DocumentParser.Classify(path)); +} + +public class WikiLinksTests +{ + [Fact] + public void ExtractsBodyLinksOutsideCodeAndRelated() + { + var text = "---\ntitle: T\nrelated: [rel-one, not a slug]\n---\n\nSee [[target-a]] and [[target-b|alias]] and [[target-c#heading]].\n`[[in-code]]`\n```\n[[fenced]]\n```\n[[ -f \"$X\" ]] and [[396,{{value|int}}]]\n"; + var doc = DocumentParser.Parse(new DocumentRef(DocumentKind.Wiki, "src", "wiki/src.md", "h"), text); + var links = WikiLinks.Extract(doc); + Assert.Equal( + [("rel-one", LinkKind.Related, (int?)null), ("target-a", LinkKind.WikiLink, 6), ("target-b", LinkKind.WikiLink, 6), ("target-c", LinkKind.WikiLink, 6)], + links.Select(l => (l.ToSlug, l.Kind, l.Line)).ToArray()); + } +} + +public class ParagraphChunkerTests +{ + [Fact] + public void SplitsOnBlankLinesHeadingsAndFences() + { + var text = "---\ntitle: UDM\n---\n\n# UDM intercepts DNS\n\nThe UDM answers every query.\n\n## Details\n\nEven dig.\nSecond line.\n\n```\ndig @10.2.40.99\n```\n"; + var doc = DocumentParser.Parse(new DocumentRef(DocumentKind.Wiki, "udm", "wiki/udm.md", "h"), text); + var ps = new ParagraphChunker().Split(doc); + Assert.Equal(3, ps.Count); + Assert.Equal("udm#p1", ps[0].Id); + Assert.Equal("UDM intercepts DNS", ps[0].Heading); + Assert.Equal("Details", ps[1].Heading); + Assert.Contains("Second line", ps[1].Text); + Assert.StartsWith("```", ps[2].Text); + } +} + +internal static class FrontmatterTests_Note +{ + public const string Text = """ + --- + title: UDM intercepts DNS + type: wiki + tags: [dns, udm] + sources: [raw/_processed/a.md, "live check: a, b"] + related: [ceph-osd, k8s-gateway-in-cluster-dns] + updated: 2026-09-16 + --- + + # UDM intercepts DNS + """; +}