From a8821f4c522242ec3caddcb568b9bc5516667b6a Mon Sep 17 00:00:00 2001 From: hardcoreerik Date: Sun, 28 Jun 2026 21:37:44 -0700 Subject: [PATCH 1/2] Build CF-4 hierarchy and cognitive paging --- .../OrchestratorIDE.Avalonia.csproj | 4 + .../ContextFabricCf4Tests.cs | 302 ++++++++++++++++++ .../ContextFabricIngestionContracts.cs | 131 ++++++++ .../ContextFabric/DocumentGraphRepository.cs | 155 +++++++++ .../ContextFabric/EvidencePackBuilder.cs | 92 ++++++ .../ContextFabric/FabricCitationVerifier.cs | 128 ++++++++ .../ContextFabric/FabricLibraryRepository.cs | 19 ++ .../ContextFabric/FabricQueryPlanner.cs | 144 +++++++++ .../Services/ContextFabric/FabricReducer.cs | 141 ++++++++ OrchestratorIDE/Services/Data/Migrations.cs | 35 ++ docs/The Orc Context Fabric.md | 8 + 11 files changed, 1159 insertions(+) create mode 100644 OrchestratorIDE.UnitTests/ContextFabricCf4Tests.cs create mode 100644 OrchestratorIDE/Services/ContextFabric/EvidencePackBuilder.cs create mode 100644 OrchestratorIDE/Services/ContextFabric/FabricCitationVerifier.cs create mode 100644 OrchestratorIDE/Services/ContextFabric/FabricQueryPlanner.cs create mode 100644 OrchestratorIDE/Services/ContextFabric/FabricReducer.cs diff --git a/OrchestratorIDE.Avalonia/OrchestratorIDE.Avalonia.csproj b/OrchestratorIDE.Avalonia/OrchestratorIDE.Avalonia.csproj index 4e00be60..d9f12bb8 100644 --- a/OrchestratorIDE.Avalonia/OrchestratorIDE.Avalonia.csproj +++ b/OrchestratorIDE.Avalonia/OrchestratorIDE.Avalonia.csproj @@ -180,6 +180,10 @@ + + + + diff --git a/OrchestratorIDE.UnitTests/ContextFabricCf4Tests.cs b/OrchestratorIDE.UnitTests/ContextFabricCf4Tests.cs new file mode 100644 index 00000000..960969c1 --- /dev/null +++ b/OrchestratorIDE.UnitTests/ContextFabricCf4Tests.cs @@ -0,0 +1,302 @@ +// Copyright (C) 2025-present hardcoreerik / TheOrc contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +using NUnit.Framework; +using OrchestratorIDE.Services.ContextFabric; +using OrchestratorIDE.Services.Data; + +namespace OrchestratorIDE.UnitTests; + +[TestFixture] +public sealed class ContextFabricCf4Tests +{ + [Test] + public void MigrationV11_Creates_Hierarchy_Tables() + { + using var store = new SqliteStore(":memory:"); + store.Initialize(); + using var connection = store.Open(); + + Assert.Multiple(() => + { + Assert.That(Scalar(connection, "SELECT COUNT(*) FROM schema_migrations WHERE version = 11"), Is.EqualTo(1)); + Assert.That(Scalar(connection, "SELECT COUNT(*) FROM sqlite_master WHERE name = 'fabric_memory_nodes'"), Is.EqualTo(1)); + Assert.That(Scalar(connection, "SELECT COUNT(*) FROM sqlite_master WHERE name = 'fabric_memory_memberships'"), Is.EqualTo(1)); + }); + } + + [Test] + public void Reducer_Persists_Expected_And_Covered_Child_Counts_And_Memberships() + { + using var harness = NewHarness(); + harness.SeedClaims("seg-0", "LANTERN is the assigned call sign."); + harness.SeedClaims("seg-1", "Emergency frequency is 17.4 MHz."); + harness.SeedClaims("seg-2", "Scouts favor the ridge route at dusk."); + + var result = new FabricReducer(harness.Library, harness.Graph, new FabricReducerOptions(FanIn: 2, MaxSummaryChars: 120)) + .ReduceDocument(harness.Document.DocumentId); + + var root = harness.Graph.GetMemoryNode(result.RootNodeId)!; + var memberships = harness.Graph.ListMemoryMemberships(result.RootNodeId); + + Assert.Multiple(() => + { + Assert.That(result.Nodes, Has.Count.GreaterThanOrEqualTo(3)); + Assert.That(root.ExpectedChildCount, Is.EqualTo(memberships.Count)); + Assert.That(root.CoveredChildCount, Is.EqualTo(memberships.Count(item => item.IsCovered))); + Assert.That(memberships.Select(item => item.ChildKind), Has.All.EqualTo("memory")); + Assert.That(memberships.Select(item => item.Ordinal), Is.EqualTo(new[] { 0, 1 })); + }); + } + + [Test] + public void Reducer_Leaves_Incomplete_Coverage_Visible_And_Not_Complete() + { + using var harness = NewHarness(); + harness.SeedClaims("seg-0", "LANTERN is the assigned call sign."); + harness.SeedClaims("seg-1", "Emergency frequency is 17.4 MHz."); + + var result = new FabricReducer(harness.Library, harness.Graph, new FabricReducerOptions(FanIn: 2, MaxSummaryChars: 120)) + .ReduceDocument(harness.Document.DocumentId); + + var root = harness.Graph.GetMemoryNode(result.RootNodeId)!; + + Assert.Multiple(() => + { + Assert.That(root.ExpectedChildCount, Is.GreaterThan(root.CoveredChildCount)); + Assert.That(root.CoverageStatus, Is.EqualTo(FabricCoverageStatus.Incomplete)); + Assert.That(result.Nodes.Any(node => node.CoverageStatus == FabricCoverageStatus.Incomplete), Is.True); + }); + } + + [Test] + public void Reducer_FanIn_Stays_Bounded() + { + using var harness = NewHarness(); + harness.SeedClaims("seg-0", "LANTERN is the assigned call sign."); + harness.SeedClaims("seg-1", "Emergency frequency is 17.4 MHz."); + harness.SeedClaims("seg-2", "Scouts favor the ridge route at dusk."); + harness.SeedClaims("seg-3", "The harbor route is safer by day."); + + var result = new FabricReducer(harness.Library, harness.Graph, new FabricReducerOptions(FanIn: 2, MaxSummaryChars: 120)) + .ReduceDocument(harness.Document.DocumentId); + + Assert.That(result.Nodes, Has.All.Matches(node => node.ExpectedChildCount <= 2)); + } + + [Test] + public void EvidencePackBuilder_Respects_8k_Budget_And_Reserves_Response_Tokens() + { + using var harness = NewHarness(); + harness.SeedClaims("seg-0", "LANTERN is the assigned call sign."); + harness.SeedClaims("seg-1", "Emergency frequency is 17.4 MHz."); + harness.SeedClaims("seg-2", "Scouts favor the ridge route at dusk."); + harness.SeedClaims("seg-3", "The harbor route is safer by day."); + + new FabricReducer(harness.Library, harness.Graph, new FabricReducerOptions(FanIn: 2, MaxSummaryChars: 80)) + .ReduceDocument(harness.Document.DocumentId); + + var plan = new FabricQueryPlanner(harness.Search, harness.Graph).BuildPlan( + "compare ridge route and harbor route", + harness.Corpus.CorpusId, + FabricQueryMode.Study, + new FabricQueryPlannerOptions(MaxPromptTokens: 8_192, ResponseTokenReserve: 1_024, MaxSourceOpens: 3)); + + var pack = new EvidencePackBuilder(harness.Library, harness.Graph).Build(plan); + + Assert.Multiple(() => + { + Assert.That(pack.WithinBudget, Is.True); + Assert.That(pack.UsedPromptTokens + pack.ResponseTokenReserve, Is.LessThanOrEqualTo(8_192)); + Assert.That(pack.Included.First().FromSource, Is.True); + Assert.That(pack.Excluded.Count, Is.GreaterThanOrEqualTo(0)); + }); + } + + [Test] + public void Quick_Mode_Works_For_Direct_Source_Backed_Questions() + { + using var harness = NewHarness(); + harness.SeedClaims("seg-0", "LANTERN is the assigned call sign."); + + var plan = new FabricQueryPlanner(harness.Search, harness.Graph).BuildPlan( + "LANTERN", + harness.Corpus.CorpusId, + null, + new FabricQueryPlannerOptions()); + + var pack = new EvidencePackBuilder(harness.Library, harness.Graph).Build(plan); + + Assert.Multiple(() => + { + Assert.That(plan.Mode, Is.EqualTo(FabricQueryMode.Quick)); + Assert.That(plan.TriggeredSourceReopen, Is.False); + Assert.That(pack.Included, Has.Some.Matches(item => item.Kind == "source" && item.Text.Contains("LANTERN", StringComparison.Ordinal))); + }); + } + + [Test] + public void Study_Mode_Triggers_Source_Reopen_When_Summaries_Are_Insufficient() + { + using var harness = NewHarness(); + harness.SeedClaims("seg-0", "LANTERN is the assigned call sign."); + harness.SeedClaims("seg-1", "Emergency frequency is 17.4 MHz."); + + new FabricReducer(harness.Library, harness.Graph, new FabricReducerOptions(FanIn: 2, MaxSummaryChars: 64)) + .ReduceDocument(harness.Document.DocumentId); + + var plan = new FabricQueryPlanner(harness.Search, harness.Graph).BuildPlan( + "how do the ridge route and harbor route compare", + harness.Corpus.CorpusId, + FabricQueryMode.Study, + new FabricQueryPlannerOptions(MaxSourceOpens: 4)); + + Assert.Multiple(() => + { + Assert.That(plan.Mode, Is.EqualTo(FabricQueryMode.Study)); + Assert.That(plan.TriggeredSourceReopen, Is.True); + Assert.That(plan.ReopenedSegmentIds, Is.Not.Empty); + }); + } + + [Test] + public void CitationVerifier_Accepts_Exact_Source_Backed_Citations() + { + using var harness = NewHarness(); + var verifier = new FabricCitationVerifier(harness.Library); + var segment = harness.Library.GetSegment("seg-0")!; + var quote = "LANTERN is the assigned call sign."; + var start = segment.Text.IndexOf(quote, StringComparison.Ordinal); + + var result = verifier.VerifyClaim( + quote, + [ + new FabricCitation + { + SegmentId = segment.SegmentId, + CharStart = start, + CharEnd = start + quote.Length, + Quote = quote, + QuoteDigest = FabricHashing.Sha256(quote) + } + ]); + + Assert.That(result.Label, Is.EqualTo(FabricCitationVerificationLabel.Supported)); + } + + [Test] + public void CitationVerifier_Rejects_Citation_Mismatches() + { + using var harness = NewHarness(); + var verifier = new FabricCitationVerifier(harness.Library); + + var result = verifier.VerifyClaim( + "LANTERN is the assigned call sign.", + [ + new FabricCitation + { + SegmentId = "seg-0", + CharStart = 0, + CharEnd = 7, + Quote = "LANTERN", + QuoteDigest = "wrong-digest" + } + ]); + + Assert.That(result.Label, Is.EqualTo(FabricCitationVerificationLabel.CitationMismatch)); + } + + private static Harness NewHarness() + { + var store = new SqliteStore(":memory:"); + store.Initialize(); + var library = new FabricLibraryRepository(store); + var graph = new DocumentGraphRepository(store); + var corpus = library.CreateCorpus("cf4-corpus", "CF-4 Lane"); + var now = DateTimeOffset.UtcNow; + var document = new FabricDocumentEntry( + "cf4-doc", + corpus.CorpusId, + "source-digest", + "normalized-digest", + "CF-4 Notes", + "text/plain", + FabricIngestionVersions.TextMarkdownParser, + FabricIngestionVersions.TextMarkdownParser, + "ready", + [], + now, + now); + + var segments = new[] + { + "LANTERN is the assigned call sign.", + "Emergency frequency is 17.4 MHz.", + "Scouts favor the ridge route at dusk.", + "The harbor route is safer by day." + }; + + var start = 0; + library.ReplaceDocument(document, segments.Select((text, index) => + { + var draft = new FabricSegmentDraft( + $"seg-{index}", + index, + $"Section {index}", + start, + start + text.Length, + Math.Max(6, text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length), + FabricHashing.Sha256(text), + text, + index > 0 ? $"seg-{index - 1}" : null, + index < segments.Length - 1 ? $"seg-{index + 1}" : null, + FabricIngestionVersions.Segmenter); + start += text.Length + 1; + return draft; + }).ToArray()); + + return new Harness(store, library, graph, new FabricSearchService(library, graph), corpus, document); + } + + private static long Scalar(Microsoft.Data.Sqlite.SqliteConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + return (long)(command.ExecuteScalar() ?? 0L); + } + + private sealed class Harness( + SqliteStore store, + FabricLibraryRepository library, + DocumentGraphRepository graph, + FabricSearchService search, + FabricCorpusEntry corpus, + FabricDocumentEntry document) : IDisposable + { + public SqliteStore Store { get; } = store; + public FabricLibraryRepository Library { get; } = library; + public DocumentGraphRepository Graph { get; } = graph; + public FabricSearchService Search { get; } = search; + public FabricCorpusEntry Corpus { get; } = corpus; + public FabricDocumentEntry Document { get; } = document; + + public void SeedClaims(string segmentId, string claimText) + { + var now = DateTimeOffset.UtcNow; + Graph.UpsertClaim( + new FabricClaimEntry( + $"claim-{segmentId}-{FabricHashing.Sha256(claimText)[..8]}", + Corpus.CorpusId, + Document.DocumentId, + segmentId, + "assertion", + claimText, + FabricVerificationStatus.Provisional, + 0.9, + now, + now), + []); + } + + public void Dispose() => Store.Dispose(); + } +} diff --git a/OrchestratorIDE/Services/ContextFabric/ContextFabricIngestionContracts.cs b/OrchestratorIDE/Services/ContextFabric/ContextFabricIngestionContracts.cs index cb2cf638..ad449234 100644 --- a/OrchestratorIDE/Services/ContextFabric/ContextFabricIngestionContracts.cs +++ b/OrchestratorIDE/Services/ContextFabric/ContextFabricIngestionContracts.cs @@ -17,6 +17,28 @@ public static class FabricVerificationStatus public const string Rejected = "rejected"; } +public static class FabricCoverageStatus +{ + public const string Complete = "complete"; + public const string Incomplete = "incomplete"; +} + +public static class FabricQueryMode +{ + public const string Quick = "quick"; + public const string Study = "study"; +} + +public static class FabricCitationVerificationLabel +{ + public const string Supported = "supported"; + public const string PartiallySupported = "partially_supported"; + public const string Contradicted = "contradicted"; + public const string CitationMismatch = "citation_mismatch"; + public const string Interpretive = "interpretive"; + public const string Unverifiable = "unverifiable"; +} + public sealed record FabricParsedBlock( int CharStart, int CharEnd, @@ -164,6 +186,115 @@ public sealed record FabricRetrievalHit( string? ClaimText, string? VerificationStatus); +public sealed record FabricMemoryNodeEntry( + string NodeId, + string CorpusId, + string DocumentId, + string NodeType, + string Title, + string SummaryText, + int Generation, + int FanIn, + int ExpectedChildCount, + int CoveredChildCount, + string CoverageStatus, + string ReducerVersion, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record FabricMemoryMembershipEntry( + string ParentNodeId, + string ChildKind, + string ChildId, + int Ordinal, + bool IsCovered); + +public sealed record FabricReducerOptions( + int FanIn = 4, + int MaxSummaryChars = 320) +{ + public void Validate() + { + if (FanIn < 2 || FanIn > 16) + throw new ArgumentOutOfRangeException(nameof(FanIn)); + if (MaxSummaryChars < 64) + throw new ArgumentOutOfRangeException(nameof(MaxSummaryChars)); + } +} + +public sealed record FabricReductionResult( + string DocumentId, + IReadOnlyList Nodes, + IReadOnlyList Memberships, + string RootNodeId); + +public sealed record FabricQueryPlannerOptions( + int RetrievalLimit = 8, + int MaxRounds = 2, + int MaxSourceOpens = 6, + int MaxPromptTokens = 8_192, + int ResponseTokenReserve = 1_024) +{ + public void Validate() + { + if (RetrievalLimit < 1 || RetrievalLimit > 64) + throw new ArgumentOutOfRangeException(nameof(RetrievalLimit)); + if (MaxRounds < 1 || MaxRounds > 8) + throw new ArgumentOutOfRangeException(nameof(MaxRounds)); + if (MaxSourceOpens < 1 || MaxSourceOpens > 64) + throw new ArgumentOutOfRangeException(nameof(MaxSourceOpens)); + if (ResponseTokenReserve < 128 || ResponseTokenReserve >= MaxPromptTokens) + throw new ArgumentOutOfRangeException(nameof(ResponseTokenReserve)); + } +} + +public sealed record FabricQueryPlan( + string Query, + string CorpusId, + string Mode, + int MaxRounds, + int MaxSourceOpens, + int MaxPromptTokens, + int ResponseTokenReserve, + IReadOnlyList SeedHits, + IReadOnlyList SummaryNodeIds, + IReadOnlyList ReopenedSegmentIds, + bool TriggeredSourceReopen); + +public sealed record FabricEvidenceItem( + string Kind, + string Id, + string Text, + int TokenCount, + bool FromSource, + string Provenance); + +public sealed record FabricEvidencePack( + string Query, + string Mode, + int PromptTokenBudget, + int ResponseTokenReserve, + int UsedPromptTokens, + IReadOnlyList Included, + IReadOnlyList Excluded, + bool WithinBudget, + bool TriggeredSourceReopen); + +public sealed record FabricCitationVerificationItem( + string SegmentId, + string Label, + string QuoteText, + int CharStart, + int CharEnd, + string Reason); + +public sealed record FabricCitationVerificationResult( + string ClaimText, + string Label, + IReadOnlyList Items, + bool Repaired, + IReadOnlyList EffectiveCitations); + public sealed record FabricSegmenterOptions( int TargetTokens = 2_000, int MaximumTokens = 3_000, diff --git a/OrchestratorIDE/Services/ContextFabric/DocumentGraphRepository.cs b/OrchestratorIDE/Services/ContextFabric/DocumentGraphRepository.cs index 0fba4cb1..ebcb2886 100644 --- a/OrchestratorIDE/Services/ContextFabric/DocumentGraphRepository.cs +++ b/OrchestratorIDE/Services/ContextFabric/DocumentGraphRepository.cs @@ -78,6 +78,23 @@ FROM fabric_claims P(ps, "$limit", Math.Clamp(limit, 1, 500)); }); + public IReadOnlyList ListClaimsForDocument(string documentId, string? verificationStatus = null, int limit = 500) => Query( + """ + SELECT * + FROM fabric_claims + WHERE document_id = $document + AND ($status IS NULL OR verification_status = $status) + ORDER BY segment_id, claim_id + LIMIT $limit + """, + MapClaim, + ps => + { + P(ps, "$document", documentId); + P(ps, "$status", verificationStatus); + P(ps, "$limit", Math.Clamp(limit, 1, 1000)); + }); + public IReadOnlyList ListClaimCitations(string claimId) => Query( """ SELECT * @@ -243,6 +260,81 @@ FROM fabric_relations P(ps, "$limit", Math.Clamp(limit, 1, 500)); }); + public void ReplaceMemoryNodesForDocument( + string documentId, + IReadOnlyList nodes, + IReadOnlyDictionary> membershipsByParentId) + { + if (string.IsNullOrWhiteSpace(documentId)) + throw new ArgumentException("Document id is required.", nameof(documentId)); + ArgumentNullException.ThrowIfNull(nodes); + ArgumentNullException.ThrowIfNull(membershipsByParentId); + if (nodes.Any(node => !string.Equals(node.DocumentId, documentId, StringComparison.Ordinal))) + throw new InvalidDataException($"Replacement memory nodes must all belong to document '{documentId}'."); + + InTransaction((conn, tx) => + { + ExecuteOn(tx, """ + DELETE FROM fabric_memory_nodes + WHERE document_id = $document + """, + ps => P(ps, "$document", documentId)); + + foreach (var node in nodes.OrderBy(item => item.Generation).ThenBy(item => item.NodeId)) + { + InsertMemoryNodeOn(conn, tx, node); + if (!membershipsByParentId.TryGetValue(node.NodeId, out var memberships)) + continue; + + foreach (var membership in memberships.OrderBy(item => item.Ordinal)) + InsertMemoryMembershipOn(conn, tx, node.NodeId, membership); + } + }); + } + + public FabricMemoryNodeEntry? GetMemoryNode(string nodeId) => Query( + "SELECT * FROM fabric_memory_nodes WHERE node_id = $id", + MapMemoryNode, + ps => P(ps, "$id", nodeId)).SingleOrDefault(); + + public IReadOnlyList ListMemoryNodes( + string corpusId, + string? documentId = null, + int? generation = null, + int limit = 200) => Query( + """ + SELECT * + FROM fabric_memory_nodes + WHERE corpus_id = $corpus + AND ($document IS NULL OR document_id = $document) + AND ($generation IS NULL OR generation = $generation) + ORDER BY generation DESC, node_id + LIMIT $limit + """, + MapMemoryNode, + ps => + { + P(ps, "$corpus", corpusId); + P(ps, "$document", documentId); + P(ps, "$generation", generation); + P(ps, "$limit", Math.Clamp(limit, 1, 1000)); + }); + + public IReadOnlyList ListMemoryMemberships(string parentNodeId) => Query( + """ + SELECT * + FROM fabric_memory_memberships + WHERE parent_node_id = $parent + ORDER BY ordinal + """, + reader => new FabricMemoryMembershipEntry( + reader.GetString(reader.GetOrdinal("parent_node_id")), + reader.GetString(reader.GetOrdinal("child_kind")), + reader.GetString(reader.GetOrdinal("child_id")), + reader.GetInt32(reader.GetOrdinal("ordinal")), + reader.GetInt32(reader.GetOrdinal("is_covered")) == 1), + ps => P(ps, "$parent", parentNodeId)); + private static FabricClaimEntry MapClaim(SqliteDataReader reader) => new( reader.GetString(reader.GetOrdinal("claim_id")), reader.GetString(reader.GetOrdinal("corpus_id")), @@ -255,6 +347,22 @@ FROM fabric_relations DateTimeOffset.Parse(reader.GetString(reader.GetOrdinal("created_at"))), DateTimeOffset.Parse(reader.GetString(reader.GetOrdinal("updated_at")))); + private static FabricMemoryNodeEntry MapMemoryNode(SqliteDataReader reader) => new( + reader.GetString(reader.GetOrdinal("node_id")), + reader.GetString(reader.GetOrdinal("corpus_id")), + reader.GetString(reader.GetOrdinal("document_id")), + reader.GetString(reader.GetOrdinal("node_type")), + reader.GetString(reader.GetOrdinal("title")), + reader.GetString(reader.GetOrdinal("summary_text")), + reader.GetInt32(reader.GetOrdinal("generation")), + reader.GetInt32(reader.GetOrdinal("fan_in")), + reader.GetInt32(reader.GetOrdinal("expected_child_count")), + reader.GetInt32(reader.GetOrdinal("covered_child_count")), + reader.GetString(reader.GetOrdinal("coverage_status")), + reader.GetString(reader.GetOrdinal("reducer_version")), + DateTimeOffset.Parse(reader.GetString(reader.GetOrdinal("created_at"))), + DateTimeOffset.Parse(reader.GetString(reader.GetOrdinal("updated_at")))); + private static string BuildFtsQuery(string query) => string.Join(" AND ", query .Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(term => $"\"{term.Replace("\"", "\"\"")}\"")); @@ -312,4 +420,51 @@ INSERT INTO fabric_claim_citations P(cmd.Parameters, "$quote", citation.QuoteText); cmd.ExecuteNonQuery(); } + + private static void InsertMemoryNodeOn(SqliteConnection conn, SqliteTransaction tx, FabricMemoryNodeEntry node) + { + using var cmd = CreateCmd(conn, tx, """ + INSERT INTO fabric_memory_nodes + (node_id, corpus_id, document_id, node_type, title, summary_text, generation, fan_in, + expected_child_count, covered_child_count, coverage_status, reducer_version, created_at, updated_at) + VALUES + ($id, $corpus, $document, $type, $title, $summary, $generation, $fanIn, + $expected, $covered, $status, $version, $created, $updated) + """); + P(cmd.Parameters, "$id", node.NodeId); + P(cmd.Parameters, "$corpus", node.CorpusId); + P(cmd.Parameters, "$document", node.DocumentId); + P(cmd.Parameters, "$type", node.NodeType); + P(cmd.Parameters, "$title", node.Title); + P(cmd.Parameters, "$summary", node.SummaryText); + P(cmd.Parameters, "$generation", node.Generation); + P(cmd.Parameters, "$fanIn", node.FanIn); + P(cmd.Parameters, "$expected", node.ExpectedChildCount); + P(cmd.Parameters, "$covered", node.CoveredChildCount); + P(cmd.Parameters, "$status", node.CoverageStatus); + P(cmd.Parameters, "$version", node.ReducerVersion); + P(cmd.Parameters, "$created", node.CreatedAt.ToString("O")); + P(cmd.Parameters, "$updated", node.UpdatedAt.ToString("O")); + cmd.ExecuteNonQuery(); + } + + private static void InsertMemoryMembershipOn( + SqliteConnection conn, + SqliteTransaction tx, + string parentNodeId, + FabricMemoryMembershipEntry membership) + { + using var cmd = CreateCmd(conn, tx, """ + INSERT INTO fabric_memory_memberships + (parent_node_id, child_kind, child_id, ordinal, is_covered) + VALUES + ($parent, $kind, $child, $ordinal, $covered) + """); + P(cmd.Parameters, "$parent", parentNodeId); + P(cmd.Parameters, "$kind", membership.ChildKind); + P(cmd.Parameters, "$child", membership.ChildId); + P(cmd.Parameters, "$ordinal", membership.Ordinal); + P(cmd.Parameters, "$covered", membership.IsCovered ? 1 : 0); + cmd.ExecuteNonQuery(); + } } diff --git a/OrchestratorIDE/Services/ContextFabric/EvidencePackBuilder.cs b/OrchestratorIDE/Services/ContextFabric/EvidencePackBuilder.cs new file mode 100644 index 00000000..cda7b16d --- /dev/null +++ b/OrchestratorIDE/Services/ContextFabric/EvidencePackBuilder.cs @@ -0,0 +1,92 @@ +// Copyright (C) 2025-present hardcoreerik / TheOrc contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OrchestratorIDE.Services.ContextFabric; + +public sealed class EvidencePackBuilder( + FabricLibraryRepository libraryRepository, + DocumentGraphRepository graphRepository) +{ + private const int BasePromptTokens = 192; + + public FabricEvidencePack Build(FabricQueryPlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + + var included = new List(); + var excluded = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var evidenceBudget = plan.MaxPromptTokens - plan.ResponseTokenReserve - BasePromptTokens; + var usedEvidenceTokens = 0; + + foreach (var item in EnumerateCandidates(plan)) + { + if (!seen.Add($"{item.Kind}:{item.Id}")) + continue; + + if (usedEvidenceTokens + item.TokenCount <= evidenceBudget) + { + included.Add(item); + usedEvidenceTokens += item.TokenCount; + } + else + { + excluded.Add(item); + } + } + + return new FabricEvidencePack( + plan.Query, + plan.Mode, + plan.MaxPromptTokens, + plan.ResponseTokenReserve, + BasePromptTokens + usedEvidenceTokens, + included, + excluded, + BasePromptTokens + usedEvidenceTokens + plan.ResponseTokenReserve <= plan.MaxPromptTokens, + plan.TriggeredSourceReopen); + } + + private IEnumerable EnumerateCandidates(FabricQueryPlan plan) + { + foreach (var segment in libraryRepository.GetSegmentsByIds(plan.ReopenedSegmentIds)) + { + yield return new FabricEvidenceItem( + "source", + segment.SegmentId, + segment.Text, + EstimateTokens(segment.Text), + true, + $"{segment.DocumentId}:{segment.Ordinal}"); + } + + foreach (var hit in plan.SeedHits) + { + yield return new FabricEvidenceItem( + "source", + hit.SegmentId, + hit.Text, + EstimateTokens(hit.Text), + true, + $"{hit.DocumentId}:{hit.Ordinal}:{hit.RetrievalPath}"); + } + + foreach (var nodeId in plan.SummaryNodeIds) + { + var node = graphRepository.GetMemoryNode(nodeId); + if (node is null) + continue; + + yield return new FabricEvidenceItem( + "summary", + node.NodeId, + node.SummaryText, + EstimateTokens(node.SummaryText), + false, + $"{node.DocumentId}:g{node.Generation}:{node.CoverageStatus}"); + } + } + + private static int EstimateTokens(string text) => + Math.Max(1, (int)Math.Ceiling(text.Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Length * 1.35)); +} diff --git a/OrchestratorIDE/Services/ContextFabric/FabricCitationVerifier.cs b/OrchestratorIDE/Services/ContextFabric/FabricCitationVerifier.cs new file mode 100644 index 00000000..fe5af02a --- /dev/null +++ b/OrchestratorIDE/Services/ContextFabric/FabricCitationVerifier.cs @@ -0,0 +1,128 @@ +// Copyright (C) 2025-present hardcoreerik / TheOrc contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OrchestratorIDE.Services.ContextFabric; + +public sealed class FabricCitationVerifier(FabricLibraryRepository libraryRepository) +{ + public FabricCitationVerificationResult VerifyClaim( + string claimText, + IReadOnlyList citations, + bool allowRepair = false) + { + if (string.IsNullOrWhiteSpace(claimText)) + throw new ArgumentException("Claim text is required.", nameof(claimText)); + ArgumentNullException.ThrowIfNull(citations); + + var repaired = false; + var items = new List(citations.Count); + var effective = new List(citations.Count); + var matchedQuotes = new List(citations.Count); + + foreach (var citation in citations) + { + if (string.IsNullOrWhiteSpace(citation.SegmentId)) + { + items.Add(new FabricCitationVerificationItem("", FabricCitationVerificationLabel.Unverifiable, citation.Quote ?? "", citation.CharStart, citation.CharEnd, "segment missing")); + continue; + } + + var segment = libraryRepository.GetSegment(citation.SegmentId); + if (segment is null) + { + items.Add(new FabricCitationVerificationItem(citation.SegmentId, FabricCitationVerificationLabel.Unverifiable, citation.Quote ?? "", citation.CharStart, citation.CharEnd, "segment not found")); + continue; + } + + if (TryMatchExact(segment.Text, citation, out var exactQuote)) + { + matchedQuotes.Add(exactQuote); + effective.Add(citation); + items.Add(new FabricCitationVerificationItem(citation.SegmentId, FabricCitationVerificationLabel.Supported, exactQuote, citation.CharStart, citation.CharEnd, "exact source match")); + continue; + } + + if (allowRepair && TryRepair(segment.Text, citation, out var repairedCitation)) + { + repaired = true; + matchedQuotes.Add(repairedCitation.Quote); + effective.Add(repairedCitation); + items.Add(new FabricCitationVerificationItem(repairedCitation.SegmentId, FabricCitationVerificationLabel.Supported, repairedCitation.Quote, repairedCitation.CharStart, repairedCitation.CharEnd, "repaired to exact source match")); + continue; + } + + items.Add(new FabricCitationVerificationItem(citation.SegmentId, FabricCitationVerificationLabel.CitationMismatch, citation.Quote ?? "", citation.CharStart, citation.CharEnd, "quote/range mismatch")); + } + + var label = ResolveLabel(claimText, items, matchedQuotes); + return new FabricCitationVerificationResult(claimText, label, items, repaired, effective); + } + + private static string ResolveLabel( + string claimText, + IReadOnlyList items, + IReadOnlyList matchedQuotes) + { + if (items.Count == 0) + return FabricCitationVerificationLabel.Unverifiable; + if (items.Any(item => item.Label == FabricCitationVerificationLabel.CitationMismatch)) + return FabricCitationVerificationLabel.CitationMismatch; + if (items.All(item => item.Label == FabricCitationVerificationLabel.Unverifiable)) + return FabricCitationVerificationLabel.Unverifiable; + + var claimTokens = Tokenize(claimText); + var sourceTokens = Tokenize(string.Join(" ", matchedQuotes)); + if (claimTokens.Count == 0 || sourceTokens.Count == 0) + return FabricCitationVerificationLabel.Interpretive; + + var overlap = claimTokens.Count(sourceTokens.Contains) / (double)claimTokens.Count; + if (overlap >= 0.95) + return FabricCitationVerificationLabel.Supported; + if (overlap >= 0.50) + return FabricCitationVerificationLabel.PartiallySupported; + if (matchedQuotes.Any(quote => quote.Contains(" not ", StringComparison.OrdinalIgnoreCase)) && + !claimText.Contains(" not ", StringComparison.OrdinalIgnoreCase)) + return FabricCitationVerificationLabel.Contradicted; + return FabricCitationVerificationLabel.Interpretive; + } + + private static bool TryMatchExact(string sourceText, FabricCitation citation, out string exactQuote) + { + exactQuote = ""; + if (citation.CharStart < 0 || citation.CharEnd <= citation.CharStart || citation.CharEnd > sourceText.Length) + return false; + + exactQuote = sourceText[citation.CharStart..citation.CharEnd]; + if (!string.Equals(citation.Quote, exactQuote, StringComparison.Ordinal)) + return false; + + var digest = FabricHashing.Sha256(exactQuote); + return string.Equals(citation.QuoteDigest, digest, StringComparison.Ordinal); + } + + private static bool TryRepair(string sourceText, FabricCitation citation, out FabricCitation repaired) + { + repaired = citation; + if (string.IsNullOrWhiteSpace(citation.Quote)) + return false; + + var start = sourceText.IndexOf(citation.Quote, StringComparison.Ordinal); + if (start < 0 || sourceText.IndexOf(citation.Quote, start + 1, StringComparison.Ordinal) >= 0) + return false; + + var digest = FabricHashing.Sha256(citation.Quote); + repaired = citation with + { + CharStart = start, + CharEnd = start + citation.Quote.Length, + QuoteDigest = digest, + }; + return true; + } + + private static HashSet Tokenize(string text) => text + .Split([' ', '\t', '\r', '\n', ',', '.', ';', ':', '?', '!'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(item => item.ToLowerInvariant()) + .Where(item => item.Length >= 3) + .ToHashSet(StringComparer.Ordinal); +} diff --git a/OrchestratorIDE/Services/ContextFabric/FabricLibraryRepository.cs b/OrchestratorIDE/Services/ContextFabric/FabricLibraryRepository.cs index ea34711a..950d828e 100644 --- a/OrchestratorIDE/Services/ContextFabric/FabricLibraryRepository.cs +++ b/OrchestratorIDE/Services/ContextFabric/FabricLibraryRepository.cs @@ -82,6 +82,25 @@ FROM fabric_segments s MapSegment, ps => P(ps, "$segment", segmentId)).SingleOrDefault(); + public IReadOnlyList GetSegmentsByIds(IEnumerable segmentIds) + { + ArgumentNullException.ThrowIfNull(segmentIds); + + var segments = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var segmentId in segmentIds) + { + if (string.IsNullOrWhiteSpace(segmentId) || !seen.Add(segmentId)) + continue; + + var segment = GetSegment(segmentId); + if (segment is not null) + segments.Add(segment); + } + + return segments; + } + public void ReplaceDocument(FabricDocumentEntry document, IReadOnlyList segments) { ArgumentNullException.ThrowIfNull(document); diff --git a/OrchestratorIDE/Services/ContextFabric/FabricQueryPlanner.cs b/OrchestratorIDE/Services/ContextFabric/FabricQueryPlanner.cs new file mode 100644 index 00000000..175ba342 --- /dev/null +++ b/OrchestratorIDE/Services/ContextFabric/FabricQueryPlanner.cs @@ -0,0 +1,144 @@ +// Copyright (C) 2025-present hardcoreerik / TheOrc contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OrchestratorIDE.Services.ContextFabric; + +public sealed class FabricQueryPlanner( + FabricSearchService searchService, + DocumentGraphRepository graphRepository) +{ + public FabricQueryPlan BuildPlan( + string query, + string corpusId, + string? mode = null, + FabricQueryPlannerOptions? options = null) + { + if (string.IsNullOrWhiteSpace(query)) + throw new ArgumentException("Query is required.", nameof(query)); + if (string.IsNullOrWhiteSpace(corpusId)) + throw new ArgumentException("Corpus id is required.", nameof(corpusId)); + + var effective = options ?? new FabricQueryPlannerOptions(); + effective.Validate(); + + var resolvedMode = string.IsNullOrWhiteSpace(mode) ? ClassifyMode(query) : mode.Trim().ToLowerInvariant(); + var hits = searchService.Search(query, corpusId, effective.RetrievalLimit); + var summaryNodeIds = hits + .Select(item => item.DocumentId) + .Distinct(StringComparer.Ordinal) + .SelectMany(documentId => + { + var highest = graphRepository.ListMemoryNodes(corpusId, documentId, limit: 16) + .GroupBy(node => node.Generation) + .OrderByDescending(group => group.Key) + .FirstOrDefault(); + return highest?.ToArray() ?? []; + }) + .Select(node => node.NodeId) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (summaryNodeIds.Length == 0) + { + summaryNodeIds = graphRepository.ListMemoryNodes(corpusId, limit: 32) + .GroupBy(node => node.DocumentId, StringComparer.Ordinal) + .SelectMany(group => + { + var highest = group + .GroupBy(node => node.Generation) + .OrderByDescending(level => level.Key) + .FirstOrDefault(); + return highest?.Select(node => node.NodeId) ?? []; + }) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + var reopenedSegmentIds = resolvedMode == FabricQueryMode.Study + ? ReopenSegments(query, summaryNodeIds, effective.MaxSourceOpens) + : []; + + return new FabricQueryPlan( + query.Trim(), + corpusId.Trim(), + resolvedMode, + effective.MaxRounds, + effective.MaxSourceOpens, + effective.MaxPromptTokens, + effective.ResponseTokenReserve, + hits, + summaryNodeIds, + reopenedSegmentIds, + reopenedSegmentIds.Count > 0); + } + + private IReadOnlyList ReopenSegments(string query, IReadOnlyList summaryNodeIds, int maxSourceOpens) + { + var queryTokens = Tokenize(query); + var reopened = new List(maxSourceOpens); + var pending = new Queue(summaryNodeIds); + var seenNodes = new HashSet(StringComparer.Ordinal); + var seenSegments = new HashSet(StringComparer.Ordinal); + + while (pending.Count > 0 && reopened.Count < maxSourceOpens) + { + var nodeId = pending.Dequeue(); + if (!seenNodes.Add(nodeId)) + continue; + + var node = graphRepository.GetMemoryNode(nodeId); + if (node is null) + continue; + + var summaryCoverage = OverlapScore(queryTokens, Tokenize(node.SummaryText)); + if (node.CoverageStatus == FabricCoverageStatus.Complete && summaryCoverage >= 0.60) + continue; + + foreach (var membership in graphRepository.ListMemoryMemberships(nodeId)) + { + if (membership.ChildKind == "segment") + { + if (seenSegments.Add(membership.ChildId)) + reopened.Add(membership.ChildId); + } + else + { + pending.Enqueue(membership.ChildId); + } + + if (reopened.Count >= maxSourceOpens) + break; + } + } + + return reopened; + } + + private static string ClassifyMode(string query) + { + var lower = query.ToLowerInvariant(); + return lower.Contains("compare", StringComparison.Ordinal) || + lower.Contains("across", StringComparison.Ordinal) || + lower.Contains("between", StringComparison.Ordinal) || + lower.Contains("change", StringComparison.Ordinal) || + lower.Contains("exception", StringComparison.Ordinal) || + lower.Contains("why", StringComparison.Ordinal) || + lower.Contains("how", StringComparison.Ordinal) + ? FabricQueryMode.Study + : FabricQueryMode.Quick; + } + + private static HashSet Tokenize(string text) => text + .Split([' ', '\t', '\r', '\n', ',', '.', ';', ':', '?', '!'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(item => item.ToLowerInvariant()) + .Where(item => item.Length >= 3) + .ToHashSet(StringComparer.Ordinal); + + private static double OverlapScore(HashSet left, HashSet right) + { + if (left.Count == 0 || right.Count == 0) + return 0; + + var matches = left.Count(right.Contains); + return matches / (double)left.Count; + } +} diff --git a/OrchestratorIDE/Services/ContextFabric/FabricReducer.cs b/OrchestratorIDE/Services/ContextFabric/FabricReducer.cs new file mode 100644 index 00000000..cecd60b7 --- /dev/null +++ b/OrchestratorIDE/Services/ContextFabric/FabricReducer.cs @@ -0,0 +1,141 @@ +// Copyright (C) 2025-present hardcoreerik / TheOrc contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OrchestratorIDE.Services.ContextFabric; + +public sealed class FabricReducer( + FabricLibraryRepository libraryRepository, + DocumentGraphRepository graphRepository, + FabricReducerOptions? options = null) +{ + private const string ReducerVersion = "fabric-reducer-1.0"; + private readonly FabricReducerOptions _options = options ?? new FabricReducerOptions(); + + public FabricReductionResult ReduceDocument(string documentId) + { + if (string.IsNullOrWhiteSpace(documentId)) + throw new ArgumentException("Document id is required.", nameof(documentId)); + + _options.Validate(); + + var document = libraryRepository.GetDocument(documentId) + ?? throw new KeyNotFoundException($"Context Fabric document '{documentId}' does not exist."); + var segments = libraryRepository.GetSegments(documentId); + if (segments.Count == 0) + throw new InvalidDataException($"Document '{documentId}' has no segments to reduce."); + + var claimsBySegment = graphRepository.ListClaimsForDocument(documentId, limit: 4_096) + .GroupBy(item => item.SegmentId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => (IReadOnlyList)group.ToArray(), StringComparer.Ordinal); + + var now = DateTimeOffset.UtcNow; + var nodes = new List(); + var memberships = new Dictionary>(StringComparer.Ordinal); + + var current = segments.Select((segment, index) => CreateLeaf(segment, index, claimsBySegment)).ToList(); + var generation = 0; + while (current.Count > 0) + { + var next = new List(); + foreach (var group in Chunk(current, _options.FanIn)) + { + var groupList = group.ToArray(); + var summary = BuildSummary(groupList); + var expected = groupList.Length; + var covered = groupList.Count(item => item.IsCovered); + var nodeId = BuildNodeId(document.DocumentId, generation, groupList[0].Ordinal, groupList[^1].Ordinal, summary); + var node = new FabricMemoryNodeEntry( + nodeId, + document.CorpusId, + document.DocumentId, + generation == 0 ? "section" : "summary", + $"{document.DisplayName} g{generation} {groupList[0].Ordinal}-{groupList[^1].Ordinal}", + summary, + generation, + _options.FanIn, + expected, + covered, + covered == expected ? FabricCoverageStatus.Complete : FabricCoverageStatus.Incomplete, + ReducerVersion, + now, + now); + + nodes.Add(node); + memberships[node.NodeId] = groupList.Select((child, ordinal) => new FabricMemoryMembershipEntry( + node.NodeId, + child.Kind, + child.Id, + ordinal, + child.IsCovered)).ToArray(); + + next.Add(new ReductionChild( + "memory", + node.NodeId, + groupList[0].Ordinal, + node.SummaryText, + node.CoverageStatus == FabricCoverageStatus.Complete)); + } + + if (next.Count == 1) + { + graphRepository.ReplaceMemoryNodesForDocument(document.DocumentId, nodes, memberships); + return new FabricReductionResult(document.DocumentId, nodes, memberships.Values.SelectMany(item => item).ToArray(), next[0].Id); + } + + current = next; + generation++; + } + + throw new InvalidOperationException("Reducer produced no memory nodes."); + } + + private ReductionChild CreateLeaf( + FabricSegmentEntry segment, + int ordinal, + IReadOnlyDictionary> claimsBySegment) + { + claimsBySegment.TryGetValue(segment.SegmentId, out var claims); + var isCovered = claims is { Count: > 0 }; + var summary = claims is { Count: > 0 } + ? string.Join(" ", claims.Select(item => item.ClaimText).Distinct(StringComparer.Ordinal)).Trim() + : segment.Text.Trim(); + + return new ReductionChild( + "segment", + segment.SegmentId, + ordinal, + TrimSummary(summary), + isCovered); + } + + private string BuildSummary(IReadOnlyList children) + { + var covered = children.Where(item => item.IsCovered).Select(item => item.SummaryText); + var summaries = covered.Any() ? covered : children.Select(item => item.SummaryText); + return TrimSummary(string.Join(" ", summaries)); + } + + private string TrimSummary(string text) + { + text = text.Trim(); + if (text.Length <= _options.MaxSummaryChars) + return text; + return text[..(_options.MaxSummaryChars - 3)].TrimEnd() + "..."; + } + + private static string BuildNodeId(string documentId, int generation, int startOrdinal, int endOrdinal, string summary) => + $"mem-{FabricHashing.Sha256($"{documentId}|{generation}|{startOrdinal}|{endOrdinal}|{summary}")[..24]}"; + + private static IEnumerable> Chunk(IReadOnlyList items, int size) + { + for (var index = 0; index < items.Count; index += size) + yield return items.Skip(index).Take(size).ToArray(); + } + + private sealed record ReductionChild( + string Kind, + string Id, + int Ordinal, + string SummaryText, + bool IsCovered); +} diff --git a/OrchestratorIDE/Services/Data/Migrations.cs b/OrchestratorIDE/Services/Data/Migrations.cs index 5c3211ad..167ced67 100644 --- a/OrchestratorIDE/Services/Data/Migrations.cs +++ b/OrchestratorIDE/Services/Data/Migrations.cs @@ -25,6 +25,7 @@ internal static class Migrations new Migration(8, "context fabric ingestion and segment search", Sql008_ContextFabric), new Migration(9, "context fabric segment integrity retrofit", Sql009_ContextFabricSegmentIntegrity), new Migration(10, "context fabric document graph and claim search", Sql010_ContextFabricDocumentGraph), + new Migration(11, "context fabric hierarchy and cognitive paging", Sql011_ContextFabricHierarchyPaging), ]; // ── v1 — Phase 1: captures + triage ───────────────────────────────────────── @@ -490,6 +491,40 @@ INSERT INTO fabric_claim_fts(rowid, claim_text) END; """; + // ── v11 — Context Fabric hierarchy + cognitive paging ────────────────── + private const string Sql011_ContextFabricHierarchyPaging = """ + CREATE TABLE fabric_memory_nodes ( + node_id TEXT PRIMARY KEY, + corpus_id TEXT NOT NULL REFERENCES fabric_corpora(corpus_id) ON DELETE CASCADE, + document_id TEXT NOT NULL REFERENCES fabric_documents(document_id) ON DELETE CASCADE, + node_type TEXT NOT NULL, + title TEXT NOT NULL, + summary_text TEXT NOT NULL, + generation INTEGER NOT NULL CHECK (generation >= 0), + fan_in INTEGER NOT NULL CHECK (fan_in >= 2), + expected_child_count INTEGER NOT NULL CHECK (expected_child_count >= 0), + covered_child_count INTEGER NOT NULL CHECK (covered_child_count >= 0 AND covered_child_count <= expected_child_count), + coverage_status TEXT NOT NULL CHECK (coverage_status IN ('complete', 'incomplete')), + reducer_version TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX ix_fabric_memory_nodes_document ON fabric_memory_nodes(document_id, generation DESC, node_id); + CREATE INDEX ix_fabric_memory_nodes_corpus ON fabric_memory_nodes(corpus_id, coverage_status, generation DESC); + + CREATE TABLE fabric_memory_memberships ( + parent_node_id TEXT NOT NULL REFERENCES fabric_memory_nodes(node_id) ON DELETE CASCADE, + child_kind TEXT NOT NULL CHECK (child_kind IN ('segment', 'memory')), + child_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + is_covered INTEGER NOT NULL CHECK (is_covered IN (0, 1)), + PRIMARY KEY (parent_node_id, child_kind, child_id), + UNIQUE (parent_node_id, ordinal) + ); + CREATE INDEX ix_fabric_memory_memberships_parent ON fabric_memory_memberships(parent_node_id, ordinal); + CREATE INDEX ix_fabric_memory_memberships_child ON fabric_memory_memberships(child_kind, child_id); + """; + // ── v5 — CodeGraph v1 (C# structure + search index) ───────────────────────── // Tables per CodeGraph_v1.md. FTS5 for BM25 search over names (camelCase split // performed at write time in GraphRepository so natural language queries hit). diff --git a/docs/The Orc Context Fabric.md b/docs/The Orc Context Fabric.md index 5c04ed41..7b9751a3 100644 --- a/docs/The Orc Context Fabric.md +++ b/docs/The Orc Context Fabric.md @@ -1316,6 +1316,14 @@ Exit gate: ### Phase CF-4: hierarchy and cognitive paging +Implementation status (2026-06-28): **framework exit gate passed in focused tests**. + +- `FabricReducer` now persists hierarchical memory nodes plus child memberships, expected-child counts, covered-child counts, coverage status, and reducer generation metadata in dedicated CF-4 tables. +- `FabricQueryPlanner` now exposes deterministic Quick and Study modes with bounded retrieval, prompt-budget, round, and source-open limits; Study mode triggers explicit source reopen when hierarchy coverage is incomplete or summaries are insufficient. +- `EvidencePackBuilder` now builds a token-bounded live context that prefers direct source segments before summaries, reserves response tokens, and records excluded evidence when the budget cuts apply. +- `FabricCitationVerifier` now reopens normalized source text through the library repository, validates exact cited ranges, and labels results as supported, partially supported, contradicted, citation mismatch, interpretive, or unverifiable without silently strengthening claims. +- `ContextFabricCf4Tests` now cover hierarchy persistence, incomplete coverage visibility, bounded fan-in, Quick vs Study planning, source rehydration, citation acceptance/rejection, and the 8K-context budget rule; the broader `FullyQualifiedName~ContextFabric` regression lane remains green. + Deliver: - `FabricReducer`; From af555fcf11b7de9788f96d491ad157500c9b6adf Mon Sep 17 00:00:00 2001 From: hardcoreerik Date: Sun, 28 Jun 2026 22:12:24 -0700 Subject: [PATCH 2/2] Address CF-4 CodeRabbit findings --- .../ContextFabricCf4Tests.cs | 282 +++++++++++++++++- .../ContextFabric/DocumentGraphRepository.cs | 55 +++- .../ContextFabric/EvidencePackBuilder.cs | 11 +- .../ContextFabric/FabricCitationVerifier.cs | 6 +- .../ContextFabric/FabricQueryPlanner.cs | 34 ++- .../Services/ContextFabric/FabricReducer.cs | 11 +- 6 files changed, 355 insertions(+), 44 deletions(-) diff --git a/OrchestratorIDE.UnitTests/ContextFabricCf4Tests.cs b/OrchestratorIDE.UnitTests/ContextFabricCf4Tests.cs index 960969c1..971ee916 100644 --- a/OrchestratorIDE.UnitTests/ContextFabricCf4Tests.cs +++ b/OrchestratorIDE.UnitTests/ContextFabricCf4Tests.cs @@ -48,6 +48,22 @@ public void Reducer_Persists_Expected_And_Covered_Child_Counts_And_Memberships() }); } + [Test] + public void Reducer_Uses_All_Claims_Up_To_Contract_Cap() + { + using var harness = NewHarness(["Base segment text."]); + var manyClaims = Enumerable.Range(0, 1_501) + .Select(index => $"Claim {index:0000} survives the reducer cap.") + .ToArray(); + foreach (var claim in manyClaims) + harness.SeedClaims("seg-0", claim); + + var result = new FabricReducer(harness.Library, harness.Graph, new FabricReducerOptions(FanIn: 2, MaxSummaryChars: 200_000)) + .ReduceDocument(harness.Document.DocumentId); + + Assert.That(result.Nodes.Single(node => node.NodeId == result.RootNodeId).SummaryText, Does.Contain("Claim 1500")); + } + [Test] public void Reducer_Leaves_Incomplete_Coverage_Visible_And_Not_Complete() { @@ -84,7 +100,7 @@ public void Reducer_FanIn_Stays_Bounded() } [Test] - public void EvidencePackBuilder_Respects_8k_Budget_And_Reserves_Response_Tokens() + public void Reducer_Root_Span_Covers_Full_Child_Range() { using var harness = NewHarness(); harness.SeedClaims("seg-0", "LANTERN is the assigned call sign."); @@ -92,6 +108,19 @@ public void EvidencePackBuilder_Respects_8k_Budget_And_Reserves_Response_Tokens( harness.SeedClaims("seg-2", "Scouts favor the ridge route at dusk."); harness.SeedClaims("seg-3", "The harbor route is safer by day."); + var result = new FabricReducer(harness.Library, harness.Graph, new FabricReducerOptions(FanIn: 2, MaxSummaryChars: 120)) + .ReduceDocument(harness.Document.DocumentId); + + Assert.That(harness.Graph.GetMemoryNode(result.RootNodeId)!.Title, Does.Contain("0-3")); + } + + [Test] + public void EvidencePackBuilder_Respects_8k_Budget_And_Reserves_Response_Tokens() + { + var minifiedJson = string.Concat(Enumerable.Repeat("{\"route\":\"ridge\",\"status\":\"safe\",\"notes\":[1,2,3,4,5]}", 80)); + using var harness = NewHarness([minifiedJson, minifiedJson, minifiedJson, minifiedJson]); + harness.SeedClaims("seg-0", "ridge route safe"); + new FabricReducer(harness.Library, harness.Graph, new FabricReducerOptions(FanIn: 2, MaxSummaryChars: 80)) .ReduceDocument(harness.Document.DocumentId); @@ -99,16 +128,17 @@ public void EvidencePackBuilder_Respects_8k_Budget_And_Reserves_Response_Tokens( "compare ridge route and harbor route", harness.Corpus.CorpusId, FabricQueryMode.Study, - new FabricQueryPlannerOptions(MaxPromptTokens: 8_192, ResponseTokenReserve: 1_024, MaxSourceOpens: 3)); + new FabricQueryPlannerOptions(MaxPromptTokens: 1_800, ResponseTokenReserve: 256, MaxSourceOpens: 4)); var pack = new EvidencePackBuilder(harness.Library, harness.Graph).Build(plan); Assert.Multiple(() => { Assert.That(pack.WithinBudget, Is.True); - Assert.That(pack.UsedPromptTokens + pack.ResponseTokenReserve, Is.LessThanOrEqualTo(8_192)); + Assert.That(pack.UsedPromptTokens + pack.ResponseTokenReserve, Is.LessThanOrEqualTo(1_800)); + Assert.That(plan.TriggeredSourceReopen, Is.True); Assert.That(pack.Included.First().FromSource, Is.True); - Assert.That(pack.Excluded.Count, Is.GreaterThanOrEqualTo(0)); + Assert.That(pack.Excluded, Is.Not.Empty); }); } @@ -134,6 +164,48 @@ public void Quick_Mode_Works_For_Direct_Source_Backed_Questions() }); } + [Test] + public void QueryPlanner_Rejects_Unknown_Explicit_Mode() + { + using var harness = NewHarness(); + + Assert.That( + () => new FabricQueryPlanner(harness.Search, harness.Graph).BuildPlan("LANTERN", harness.Corpus.CorpusId, "exhaustive-ish"), + Throws.TypeOf()); + } + + [Test] + public void QueryPlanner_Trims_Query_And_CorpusId_Before_Search() + { + using var harness = NewHarness(); + harness.SeedClaims("seg-0", "LANTERN is the assigned call sign."); + + var plan = new FabricQueryPlanner(harness.Search, harness.Graph).BuildPlan( + " LANTERN ", + $" {harness.Corpus.CorpusId} "); + + Assert.Multiple(() => + { + Assert.That(plan.Query, Is.EqualTo("LANTERN")); + Assert.That(plan.CorpusId, Is.EqualTo(harness.Corpus.CorpusId)); + Assert.That(plan.SeedHits, Is.Not.Empty); + }); + } + + [Test] + public void QueryPlanner_Classifies_Study_Keywords_By_Token() + { + using var harness = NewHarness(); + var planner = new FabricQueryPlanner(harness.Search, harness.Graph); + + Assert.Multiple(() => + { + Assert.That(planner.BuildPlan("show me LANTERN", harness.Corpus.CorpusId).Mode, Is.EqualTo(FabricQueryMode.Quick)); + Assert.That(planner.BuildPlan("exchange frequency", harness.Corpus.CorpusId).Mode, Is.EqualTo(FabricQueryMode.Quick)); + Assert.That(planner.BuildPlan("how does X compare", harness.Corpus.CorpusId).Mode, Is.EqualTo(FabricQueryMode.Study)); + }); + } + [Test] public void Study_Mode_Triggers_Source_Reopen_When_Summaries_Are_Insufficient() { @@ -205,16 +277,139 @@ public void CitationVerifier_Rejects_Citation_Mismatches() Assert.That(result.Label, Is.EqualTo(FabricCitationVerificationLabel.CitationMismatch)); } - private static Harness NewHarness() + [Test] + public void CitationVerifier_Rejects_Symmetric_Negation_Mismatches_As_Contradicted() + { + using var harness = NewHarness(["The route is not safe in high wind."]); + var verifier = new FabricCitationVerifier(harness.Library); + var quote = "The route is not safe in high wind."; + + var result = verifier.VerifyClaim( + "The route is safe in high wind.", + [ + new FabricCitation + { + SegmentId = "seg-0", + CharStart = 0, + CharEnd = quote.Length, + Quote = quote, + QuoteDigest = FabricHashing.Sha256(quote) + } + ]); + + Assert.That(result.Label, Is.EqualTo(FabricCitationVerificationLabel.Contradicted)); + } + + [Test] + public void ReplaceMemoryNodesForDocument_Rejects_Foreign_Segment_Membership() + { + using var harness = NewHarness(); + harness.AddDocument("cf4-doc-b", ["Foreign segment."], "cf4b-seg"); + + var parent = NewNode(harness, "node-parent", 0, "Parent", 1, 1, FabricCoverageStatus.Complete); + + Assert.That( + () => harness.Graph.ReplaceMemoryNodesForDocument( + harness.Document.DocumentId, + [parent], + new Dictionary>(StringComparer.Ordinal) + { + [parent.NodeId] = + [ + new FabricMemoryMembershipEntry(parent.NodeId, "segment", "cf4b-seg-0", 0, true) + ] + }), + Throws.TypeOf()); + } + + [Test] + public void ReplaceMemoryNodesForDocument_Rejects_Missing_Memory_Child() + { + using var harness = NewHarness(); + var parent = NewNode(harness, "node-parent", 0, "Parent", 1, 1, FabricCoverageStatus.Complete); + + Assert.That( + () => harness.Graph.ReplaceMemoryNodesForDocument( + harness.Document.DocumentId, + [parent], + new Dictionary>(StringComparer.Ordinal) + { + [parent.NodeId] = + [ + new FabricMemoryMembershipEntry(parent.NodeId, "memory", "missing-child", 0, true) + ] + }), + Throws.TypeOf()); + } + + [Test] + public void ReplaceMemoryNodesForDocument_Rejects_Mismatched_Parent() + { + using var harness = NewHarness(); + var parent = NewNode(harness, "node-parent", 0, "Parent", 1, 1, FabricCoverageStatus.Complete); + + Assert.That( + () => harness.Graph.ReplaceMemoryNodesForDocument( + harness.Document.DocumentId, + [parent], + new Dictionary>(StringComparer.Ordinal) + { + [parent.NodeId] = + [ + new FabricMemoryMembershipEntry("wrong-parent", "segment", "seg-0", 0, true) + ] + }), + Throws.TypeOf()); + } + + [Test] + public void ReplaceMemoryNodesForDocument_Does_Not_Partially_Replace_On_Invalid_Membership() + { + using var harness = NewHarness(); + var existingParent = NewNode(harness, "existing-parent", 0, "Existing", 1, 1, FabricCoverageStatus.Complete); + harness.Graph.ReplaceMemoryNodesForDocument( + harness.Document.DocumentId, + [existingParent], + new Dictionary>(StringComparer.Ordinal) + { + [existingParent.NodeId] = + [ + new FabricMemoryMembershipEntry(existingParent.NodeId, "segment", "seg-0", 0, true) + ] + }); + + var replacementParent = NewNode(harness, "replacement-parent", 0, "Replacement", 1, 1, FabricCoverageStatus.Complete); + + Assert.That( + () => harness.Graph.ReplaceMemoryNodesForDocument( + harness.Document.DocumentId, + [replacementParent], + new Dictionary>(StringComparer.Ordinal) + { + [replacementParent.NodeId] = + [ + new FabricMemoryMembershipEntry(replacementParent.NodeId, "memory", "missing-child", 0, true) + ] + }), + Throws.TypeOf()); + + Assert.Multiple(() => + { + Assert.That(harness.Graph.GetMemoryNode(existingParent.NodeId), Is.Not.Null); + Assert.That(harness.Graph.GetMemoryNode(replacementParent.NodeId), Is.Null); + }); + } + + private static Harness NewHarness(IEnumerable? segments = null, string corpusId = "cf4-corpus", string documentId = "cf4-doc") { var store = new SqliteStore(":memory:"); store.Initialize(); var library = new FabricLibraryRepository(store); var graph = new DocumentGraphRepository(store); - var corpus = library.CreateCorpus("cf4-corpus", "CF-4 Lane"); + var corpus = library.CreateCorpus(corpusId, "CF-4 Lane"); var now = DateTimeOffset.UtcNow; var document = new FabricDocumentEntry( - "cf4-doc", + documentId, corpus.CorpusId, "source-digest", "normalized-digest", @@ -227,16 +422,15 @@ private static Harness NewHarness() now, now); - var segments = new[] - { + var texts = (segments ?? [ "LANTERN is the assigned call sign.", "Emergency frequency is 17.4 MHz.", "Scouts favor the ridge route at dusk.", "The harbor route is safer by day." - }; + ]).ToArray(); var start = 0; - library.ReplaceDocument(document, segments.Select((text, index) => + library.ReplaceDocument(document, texts.Select((text, index) => { var draft = new FabricSegmentDraft( $"seg-{index}", @@ -248,7 +442,7 @@ private static Harness NewHarness() FabricHashing.Sha256(text), text, index > 0 ? $"seg-{index - 1}" : null, - index < segments.Length - 1 ? $"seg-{index + 1}" : null, + index < texts.Length - 1 ? $"seg-{index + 1}" : null, FabricIngestionVersions.Segmenter); start += text.Length + 1; return draft; @@ -257,6 +451,33 @@ private static Harness NewHarness() return new Harness(store, library, graph, new FabricSearchService(library, graph), corpus, document); } + private static FabricMemoryNodeEntry NewNode( + Harness harness, + string nodeId, + int generation, + string summary, + int expected, + int covered, + string coverageStatus) + { + var now = DateTimeOffset.UtcNow; + return new FabricMemoryNodeEntry( + nodeId, + harness.Corpus.CorpusId, + harness.Document.DocumentId, + "summary", + summary, + summary, + generation, + 2, + expected, + covered, + coverageStatus, + "test", + now, + now); + } + private static long Scalar(Microsoft.Data.Sqlite.SqliteConnection connection, string sql) { using var command = connection.CreateCommand(); @@ -297,6 +518,43 @@ public void SeedClaims(string segmentId, string claimText) []); } + public void AddDocument(string documentId, IReadOnlyList segments, string segmentPrefix) + { + var now = DateTimeOffset.UtcNow; + var document = new FabricDocumentEntry( + documentId, + Corpus.CorpusId, + $"{documentId}-source-digest", + $"{documentId}-normalized-digest", + documentId, + "text/plain", + FabricIngestionVersions.TextMarkdownParser, + FabricIngestionVersions.TextMarkdownParser, + "ready", + [], + now, + now); + + var start = 0; + Library.ReplaceDocument(document, segments.Select((text, index) => + { + var draft = new FabricSegmentDraft( + $"{segmentPrefix}-{index}", + index, + $"{documentId}-{index}", + start, + start + text.Length, + Math.Max(6, text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length), + FabricHashing.Sha256(text), + text, + index > 0 ? $"{segmentPrefix}-{index - 1}" : null, + index < segments.Count - 1 ? $"{segmentPrefix}-{index + 1}" : null, + FabricIngestionVersions.Segmenter); + start += text.Length + 1; + return draft; + }).ToArray()); + } + public void Dispose() => Store.Dispose(); } } diff --git a/OrchestratorIDE/Services/ContextFabric/DocumentGraphRepository.cs b/OrchestratorIDE/Services/ContextFabric/DocumentGraphRepository.cs index ebcb2886..ebc2dcd7 100644 --- a/OrchestratorIDE/Services/ContextFabric/DocumentGraphRepository.cs +++ b/OrchestratorIDE/Services/ContextFabric/DocumentGraphRepository.cs @@ -92,7 +92,7 @@ FROM fabric_claims { P(ps, "$document", documentId); P(ps, "$status", verificationStatus); - P(ps, "$limit", Math.Clamp(limit, 1, 1000)); + P(ps, "$limit", Math.Clamp(limit, 1, 4096)); }); public IReadOnlyList ListClaimCitations(string claimId) => Query( @@ -271,6 +271,7 @@ public void ReplaceMemoryNodesForDocument( ArgumentNullException.ThrowIfNull(membershipsByParentId); if (nodes.Any(node => !string.Equals(node.DocumentId, documentId, StringComparison.Ordinal))) throw new InvalidDataException($"Replacement memory nodes must all belong to document '{documentId}'."); + ValidateMemoryMemberships(documentId, nodes, membershipsByParentId); InTransaction((conn, tx) => { @@ -287,7 +288,7 @@ DELETE FROM fabric_memory_nodes continue; foreach (var membership in memberships.OrderBy(item => item.Ordinal)) - InsertMemoryMembershipOn(conn, tx, node.NodeId, membership); + InsertMemoryMembershipOn(conn, tx, membership); } }); } @@ -367,6 +368,53 @@ private static string BuildFtsQuery(string query) => string.Join(" AND ", query .Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(term => $"\"{term.Replace("\"", "\"\"")}\"")); + private void ValidateMemoryMemberships( + string documentId, + IReadOnlyList nodes, + IReadOnlyDictionary> membershipsByParentId) + { + var replacementNodes = nodes.ToDictionary(node => node.NodeId, StringComparer.Ordinal); + + foreach (var parentId in membershipsByParentId.Keys) + { + if (!replacementNodes.ContainsKey(parentId)) + throw new InvalidDataException($"Memory memberships reference missing parent node '{parentId}'."); + } + + foreach (var (parentId, memberships) in membershipsByParentId) + { + var seenOrdinals = new HashSet(); + foreach (var membership in memberships) + { + if (!string.Equals(membership.ParentNodeId, parentId, StringComparison.Ordinal)) + throw new InvalidDataException($"Memory membership parent '{membership.ParentNodeId}' does not match '{parentId}'."); + if (membership.ChildKind is not ("segment" or "memory")) + throw new InvalidDataException($"Memory membership child kind '{membership.ChildKind}' is not supported."); + if (!seenOrdinals.Add(membership.Ordinal)) + throw new InvalidDataException($"Memory memberships for '{parentId}' contain duplicate ordinal '{membership.Ordinal}'."); + + if (membership.ChildKind == "segment") + { + var segment = Query( + "SELECT document_id FROM fabric_segments WHERE segment_id = $id", + reader => reader.GetString(reader.GetOrdinal("document_id")), + ps => P(ps, "$id", membership.ChildId)).SingleOrDefault(); + if (segment is null) + throw new InvalidDataException($"Memory membership references missing segment '{membership.ChildId}'."); + if (!string.Equals(segment, documentId, StringComparison.Ordinal)) + throw new InvalidDataException($"Memory membership segment '{membership.ChildId}' does not belong to document '{documentId}'."); + } + else + { + if (!replacementNodes.TryGetValue(membership.ChildId, out var childNode)) + throw new InvalidDataException($"Memory membership references missing child node '{membership.ChildId}'."); + if (!string.Equals(childNode.DocumentId, documentId, StringComparison.Ordinal)) + throw new InvalidDataException($"Memory membership child node '{membership.ChildId}' does not belong to document '{documentId}'."); + } + } + } + } + private static void UpsertClaimOn(SqliteConnection conn, SqliteTransaction tx, FabricClaimEntry claim) { using var cmd = CreateCmd(conn, tx, """ @@ -451,7 +499,6 @@ INSERT INTO fabric_memory_nodes private static void InsertMemoryMembershipOn( SqliteConnection conn, SqliteTransaction tx, - string parentNodeId, FabricMemoryMembershipEntry membership) { using var cmd = CreateCmd(conn, tx, """ @@ -460,7 +507,7 @@ INSERT INTO fabric_memory_memberships VALUES ($parent, $kind, $child, $ordinal, $covered) """); - P(cmd.Parameters, "$parent", parentNodeId); + P(cmd.Parameters, "$parent", membership.ParentNodeId); P(cmd.Parameters, "$kind", membership.ChildKind); P(cmd.Parameters, "$child", membership.ChildId); P(cmd.Parameters, "$ordinal", membership.Ordinal); diff --git a/OrchestratorIDE/Services/ContextFabric/EvidencePackBuilder.cs b/OrchestratorIDE/Services/ContextFabric/EvidencePackBuilder.cs index cda7b16d..57a8c20c 100644 --- a/OrchestratorIDE/Services/ContextFabric/EvidencePackBuilder.cs +++ b/OrchestratorIDE/Services/ContextFabric/EvidencePackBuilder.cs @@ -1,5 +1,6 @@ // Copyright (C) 2025-present hardcoreerik / TheOrc contributors // SPDX-License-Identifier: AGPL-3.0-or-later +using OrchestratorIDE.Core; namespace OrchestratorIDE.Services.ContextFabric; @@ -55,7 +56,7 @@ private IEnumerable EnumerateCandidates(FabricQueryPlan plan "source", segment.SegmentId, segment.Text, - EstimateTokens(segment.Text), + EstimatePromptTokensConservatively(segment.Text), true, $"{segment.DocumentId}:{segment.Ordinal}"); } @@ -66,7 +67,7 @@ private IEnumerable EnumerateCandidates(FabricQueryPlan plan "source", hit.SegmentId, hit.Text, - EstimateTokens(hit.Text), + EstimatePromptTokensConservatively(hit.Text), true, $"{hit.DocumentId}:{hit.Ordinal}:{hit.RetrievalPath}"); } @@ -81,12 +82,12 @@ private IEnumerable EnumerateCandidates(FabricQueryPlan plan "summary", node.NodeId, node.SummaryText, - EstimateTokens(node.SummaryText), + EstimatePromptTokensConservatively(node.SummaryText), false, $"{node.DocumentId}:g{node.Generation}:{node.CoverageStatus}"); } } - private static int EstimateTokens(string text) => - Math.Max(1, (int)Math.Ceiling(text.Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Length * 1.35)); + private static int EstimatePromptTokensConservatively(string text) => + ContextManager.EstimateTokens(text ?? ""); } diff --git a/OrchestratorIDE/Services/ContextFabric/FabricCitationVerifier.cs b/OrchestratorIDE/Services/ContextFabric/FabricCitationVerifier.cs index fe5af02a..5df36da9 100644 --- a/OrchestratorIDE/Services/ContextFabric/FabricCitationVerifier.cs +++ b/OrchestratorIDE/Services/ContextFabric/FabricCitationVerifier.cs @@ -74,15 +74,14 @@ private static string ResolveLabel( var sourceTokens = Tokenize(string.Join(" ", matchedQuotes)); if (claimTokens.Count == 0 || sourceTokens.Count == 0) return FabricCitationVerificationLabel.Interpretive; + if (claimTokens.Contains("not") != sourceTokens.Contains("not")) + return FabricCitationVerificationLabel.Contradicted; var overlap = claimTokens.Count(sourceTokens.Contains) / (double)claimTokens.Count; if (overlap >= 0.95) return FabricCitationVerificationLabel.Supported; if (overlap >= 0.50) return FabricCitationVerificationLabel.PartiallySupported; - if (matchedQuotes.Any(quote => quote.Contains(" not ", StringComparison.OrdinalIgnoreCase)) && - !claimText.Contains(" not ", StringComparison.OrdinalIgnoreCase)) - return FabricCitationVerificationLabel.Contradicted; return FabricCitationVerificationLabel.Interpretive; } @@ -123,6 +122,5 @@ private static bool TryRepair(string sourceText, FabricCitation citation, out Fa private static HashSet Tokenize(string text) => text .Split([' ', '\t', '\r', '\n', ',', '.', ';', ':', '?', '!'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(item => item.ToLowerInvariant()) - .Where(item => item.Length >= 3) .ToHashSet(StringComparer.Ordinal); } diff --git a/OrchestratorIDE/Services/ContextFabric/FabricQueryPlanner.cs b/OrchestratorIDE/Services/ContextFabric/FabricQueryPlanner.cs index 175ba342..11b39949 100644 --- a/OrchestratorIDE/Services/ContextFabric/FabricQueryPlanner.cs +++ b/OrchestratorIDE/Services/ContextFabric/FabricQueryPlanner.cs @@ -21,14 +21,18 @@ public FabricQueryPlan BuildPlan( var effective = options ?? new FabricQueryPlannerOptions(); effective.Validate(); - var resolvedMode = string.IsNullOrWhiteSpace(mode) ? ClassifyMode(query) : mode.Trim().ToLowerInvariant(); - var hits = searchService.Search(query, corpusId, effective.RetrievalLimit); + var normalizedQuery = query.Trim(); + var normalizedCorpusId = corpusId.Trim(); + var resolvedMode = string.IsNullOrWhiteSpace(mode) + ? ClassifyMode(normalizedQuery) + : NormalizeMode(mode); + var hits = searchService.Search(normalizedQuery, normalizedCorpusId, effective.RetrievalLimit); var summaryNodeIds = hits .Select(item => item.DocumentId) .Distinct(StringComparer.Ordinal) .SelectMany(documentId => { - var highest = graphRepository.ListMemoryNodes(corpusId, documentId, limit: 16) + var highest = graphRepository.ListMemoryNodes(normalizedCorpusId, documentId, limit: 16) .GroupBy(node => node.Generation) .OrderByDescending(group => group.Key) .FirstOrDefault(); @@ -39,7 +43,7 @@ public FabricQueryPlan BuildPlan( .ToArray(); if (summaryNodeIds.Length == 0) { - summaryNodeIds = graphRepository.ListMemoryNodes(corpusId, limit: 32) + summaryNodeIds = graphRepository.ListMemoryNodes(normalizedCorpusId, limit: 32) .GroupBy(node => node.DocumentId, StringComparer.Ordinal) .SelectMany(group => { @@ -54,12 +58,12 @@ public FabricQueryPlan BuildPlan( } var reopenedSegmentIds = resolvedMode == FabricQueryMode.Study - ? ReopenSegments(query, summaryNodeIds, effective.MaxSourceOpens) + ? ReopenSegments(normalizedQuery, summaryNodeIds, effective.MaxSourceOpens) : []; return new FabricQueryPlan( - query.Trim(), - corpusId.Trim(), + normalizedQuery, + normalizedCorpusId, resolvedMode, effective.MaxRounds, effective.MaxSourceOpens, @@ -71,6 +75,13 @@ public FabricQueryPlan BuildPlan( reopenedSegmentIds.Count > 0); } + private static string NormalizeMode(string mode) => mode.Trim().ToLowerInvariant() switch + { + FabricQueryMode.Quick => FabricQueryMode.Quick, + FabricQueryMode.Study => FabricQueryMode.Study, + _ => throw new ArgumentOutOfRangeException(nameof(mode), $"Unsupported Context Fabric query mode '{mode}'."), + }; + private IReadOnlyList ReopenSegments(string query, IReadOnlyList summaryNodeIds, int maxSourceOpens) { var queryTokens = Tokenize(query); @@ -115,14 +126,7 @@ private IReadOnlyList ReopenSegments(string query, IReadOnlyList private static string ClassifyMode(string query) { - var lower = query.ToLowerInvariant(); - return lower.Contains("compare", StringComparison.Ordinal) || - lower.Contains("across", StringComparison.Ordinal) || - lower.Contains("between", StringComparison.Ordinal) || - lower.Contains("change", StringComparison.Ordinal) || - lower.Contains("exception", StringComparison.Ordinal) || - lower.Contains("why", StringComparison.Ordinal) || - lower.Contains("how", StringComparison.Ordinal) + return Tokenize(query).Overlaps(["compare", "across", "between", "change", "exception", "why", "how"]) ? FabricQueryMode.Study : FabricQueryMode.Quick; } diff --git a/OrchestratorIDE/Services/ContextFabric/FabricReducer.cs b/OrchestratorIDE/Services/ContextFabric/FabricReducer.cs index cecd60b7..738c02ac 100644 --- a/OrchestratorIDE/Services/ContextFabric/FabricReducer.cs +++ b/OrchestratorIDE/Services/ContextFabric/FabricReducer.cs @@ -43,13 +43,13 @@ public FabricReductionResult ReduceDocument(string documentId) var summary = BuildSummary(groupList); var expected = groupList.Length; var covered = groupList.Count(item => item.IsCovered); - var nodeId = BuildNodeId(document.DocumentId, generation, groupList[0].Ordinal, groupList[^1].Ordinal, summary); + var nodeId = BuildNodeId(document.DocumentId, generation, groupList[0].StartOrdinal, groupList[^1].EndOrdinal, summary); var node = new FabricMemoryNodeEntry( nodeId, document.CorpusId, document.DocumentId, generation == 0 ? "section" : "summary", - $"{document.DisplayName} g{generation} {groupList[0].Ordinal}-{groupList[^1].Ordinal}", + $"{document.DisplayName} g{generation} {groupList[0].StartOrdinal}-{groupList[^1].EndOrdinal}", summary, generation, _options.FanIn, @@ -71,7 +71,8 @@ public FabricReductionResult ReduceDocument(string documentId) next.Add(new ReductionChild( "memory", node.NodeId, - groupList[0].Ordinal, + groupList[0].StartOrdinal, + groupList[^1].EndOrdinal, node.SummaryText, node.CoverageStatus == FabricCoverageStatus.Complete)); } @@ -104,6 +105,7 @@ private ReductionChild CreateLeaf( "segment", segment.SegmentId, ordinal, + ordinal, TrimSummary(summary), isCovered); } @@ -135,7 +137,8 @@ private static IEnumerable> Chunk(IReadOnlyList items, in private sealed record ReductionChild( string Kind, string Id, - int Ordinal, + int StartOrdinal, + int EndOrdinal, string SummaryText, bool IsCovered); }