|
// 00-problem-baseline.cs |
|
// SPIKE 0 — Prove or KILL hypothesis H1. |
|
// |
|
// H1: With MEDI PR #7516's current first-wins metadata propagation, a chunk that spans |
|
// multiple source pages produces a WRONG/INCOMPLETE source citation, AND the typed |
|
// PageNumber (the field that actually matters for citation) is dropped entirely. |
|
// |
|
// JTBD under test: "Given a retrieved chunk, what page(s) of the source does it cite?" |
|
// This is the #1 production-RAG requirement (legal/medical/financial attribution). |
|
// |
|
// Run: /home/luquinta/.dotnet/dotnet run 00-problem-baseline.cs |
|
// |
|
// Faithful standalone analogs of the real MEDI types (kept inline so the spike is fast and |
|
// avoids the cross-platform repo's build gremlins). Mapping to the real abstractions: |
|
// Element -> Microsoft.Extensions.DataIngestion.IngestionDocumentElement |
|
// (typed `int? PageNumber`, `IDictionary<string,object?> Metadata`) |
|
// Chunk -> Microsoft.Extensions.DataIngestion.IngestionChunk<T> |
|
// (`IDictionary<string,object> Metadata`) |
|
// The first-wins merge below mirrors #7516's ElementsChunker.AccumulateMetadata (TryAdd). |
|
|
|
using System; |
|
using System.Collections.Generic; |
|
using System.Linq; |
|
|
|
// ---- MEDI analogs ----------------------------------------------------------------------- |
|
|
|
// Mirrors IngestionDocumentElement: typed PageNumber + a loose nullable metadata dict. |
|
sealed class Element |
|
{ |
|
public string Text { get; init; } = ""; |
|
public int? PageNumber { get; init; } // TYPED signal (the citation field) |
|
public Dictionary<string, object?> Metadata { get; } = new(); // loose parse-time bag |
|
} |
|
|
|
// Mirrors IngestionChunk<T>: non-null metadata dict. No typed provenance today. |
|
sealed class Chunk |
|
{ |
|
public string Content { get; init; } = ""; |
|
public Dictionary<string, object> Metadata { get; } = new(); |
|
} |
|
|
|
// ---- #7516 behavior: ElementsChunker.AccumulateMetadata (first-wins) --------------------- |
|
|
|
static class Pr7516Chunker |
|
{ |
|
// Merge a run of elements into ONE chunk, replicating #7516: |
|
// - copies element.Metadata (loose dict) into the chunk via TryAdd => FIRST-WINS on collision |
|
// - IGNORES the typed PageNumber entirely |
|
// - skips null values |
|
public static Chunk Merge(IEnumerable<Element> elements) |
|
{ |
|
var els = elements.ToList(); |
|
var chunk = new Chunk { Content = string.Join(" ", els.Select(e => e.Text)) }; |
|
|
|
foreach (var el in els) |
|
{ |
|
foreach (var kv in el.Metadata) |
|
{ |
|
if (kv.Value is null) continue; // skip nulls (as #7516 does) |
|
if (!chunk.Metadata.ContainsKey(kv.Key)) // TryAdd == first-wins |
|
chunk.Metadata[kv.Key] = kv.Value!; |
|
} |
|
// NOTE: el.PageNumber (the typed citation field) is never propagated. <-- the defect |
|
} |
|
return chunk; |
|
} |
|
} |
|
|
|
// ---- Scenario: a contract whose clause spans pages 1, 2, 3 ------------------------------- |
|
|
|
static class Program |
|
{ |
|
static void Main() |
|
{ |
|
// A "Limitation of Liability" clause flows across three pages. A semantic chunker keeps |
|
// the clause together as ONE chunk -> that chunk legitimately spans pages 1-3. |
|
// Each element carries the typed PageNumber (the real signal). Some readers ALSO stuff a |
|
// loose "page" key into the dict; we include it to show first-wins lossiness on the dict too. |
|
var elements = new List<Element> |
|
{ |
|
new() { Text = "8. LIMITATION OF LIABILITY. In no event shall either party", |
|
PageNumber = 1, Metadata = { ["page"] = 1, ["bbox"] = "72,90,540,120" } }, |
|
new() { Text = "be liable for any indirect, incidental, or consequential damages", |
|
PageNumber = 2, Metadata = { ["page"] = 2, ["bbox"] = "72,90,540,118" } }, |
|
new() { Text = "arising out of or related to this Agreement, even if advised.", |
|
PageNumber = 3, Metadata = { ["page"] = 3, ["bbox"] = "72,90,540,116" } }, |
|
}; |
|
|
|
int[] truePages = elements.Select(e => e.PageNumber!.Value).Distinct().OrderBy(p => p).ToArray(); |
|
|
|
var chunk = Pr7516Chunker.Merge(elements); |
|
|
|
// ---- The JTBD: cite the source page(s) for this retrieved chunk -------------------- |
|
// A consumer can only look at what the chunk actually carries: chunk.Metadata. |
|
int[] citedPages = ExtractCitedPages(chunk); |
|
|
|
Console.WriteLine("=== SPIKE 0: baseline (#7516 first-wins) — citation JTBD ===\n"); |
|
Console.WriteLine($"Chunk content : \"{chunk.Content}\""); |
|
Console.WriteLine($"True source pages (from typed PageNumber): [{string.Join(", ", truePages)}]"); |
|
Console.WriteLine($"Chunk metadata carried : {{ {string.Join(", ", chunk.Metadata.Select(kv => $"{kv.Key}={kv.Value}"))} }}"); |
|
Console.WriteLine($"Typed page provenance on chunk: (none — chunk has no typed page field)"); |
|
Console.WriteLine($"Pages a consumer CAN cite : [{string.Join(", ", citedPages)}]\n"); |
|
|
|
bool citationCorrect = citedPages.SequenceEqual(truePages); |
|
bool typedPageDropped = true; // chunk model has no typed page field, and #7516 never set one |
|
|
|
Console.WriteLine("---- VERDICT ----"); |
|
Console.WriteLine($"Citation correct & complete : {(citationCorrect ? "YES ✅" : "NO ❌")} " + |
|
$"(cited [{string.Join(",", citedPages)}] vs true [{string.Join(",", truePages)}])"); |
|
Console.WriteLine($"Typed PageNumber propagated : {(typedPageDropped ? "NO ❌ (dropped)" : "yes")}"); |
|
Console.WriteLine($"Loose-dict page is lossy : first-wins kept page={chunk.Metadata.GetValueOrDefault("page")}, dropped {truePages.Length - 1} of {truePages.Length}"); |
|
Console.WriteLine(); |
|
Console.WriteLine(citationCorrect |
|
? "H1 FALSIFIED — citation already works. STOP; recommend shrinking/closing #7516." |
|
: "H1 CONFIRMED — first-wins breaks cross-page citation and drops the typed page field.\n" + |
|
" -> a minimal, citation-critical fix is warranted (see spike 01)."); |
|
} |
|
|
|
// A realistic consumer's best effort: read whatever page signal the chunk carries. |
|
// With #7516 that's only the loose "page" key (first-wins), so it sees a single page. |
|
static int[] ExtractCitedPages(Chunk chunk) |
|
{ |
|
if (chunk.Metadata.TryGetValue("page", out var p) && p is int page) |
|
return new[] { page }; |
|
if (chunk.Metadata.TryGetValue("page_number", out var p2) && p2 is int page2) |
|
return new[] { page2 }; |
|
return Array.Empty<int>(); |
|
} |
|
} |