using System; using System.Collections.Generic; using IslaApocalypse.Core; namespace IslaApocalypse.Tools { /// /// ⭐⭐ THE ORACLE — the automatic correctness checks that let the developer's eye judge ONLY /// relief. /// /// ═══ WHY THIS EXISTS AT ALL ═══ /// /// The prototype's single most valuable terrain lesson was not about terrain: /// /// > *"Five rounds of taste-iteration were safe BECAUSE correctness was not being judged by eye. /// > Where a future phase has a subjective gate, ask first what the automatic invariant is."* /// > — `Design - Tooling - Iteration and Batching.md`, "build the oracle before the /// > taste-iteration, not after". /// /// The curve is a subjective gate. So before a single render is looked at, four things are /// proven mechanically: /// /// (a) REGRESSION curve OFF is bit-identical to Phase 1's pass-1 output. /// ⇒ the port disturbed nothing upstream. /// (b) CLASSIFY FIDELITY the classify field is bit-identical to the raw pre-curve field, /// curve on or off. ⇒ the oracle field is actually an oracle. /// (c) MONOTONICITY the effective per-seed curve is strictly increasing everywhere. /// ⇒ no peak has become a pit. /// (d) BAND SHARES realized land shares match the 60/13/10/5/8/3/1 targets. /// ⇒ the calibration did what it claimed. /// /// ⚠ BIT-IDENTICAL MEANS BIT-IDENTICAL. These compare IEEE-754 bit patterns, not values within /// an epsilon. "Close enough" is how a drift becomes a fact — and the whole point of the `.f32` /// dump is that two generators agree or their dumps differ. /// /// ⚠ Any failure fails the TASK, loudly. Nothing here papers over a mismatch: a check that /// reports "mostly passed" is a check that has stopped working. /// public static class ShapingOracle { /// One check's verdict. carries the evidence either way. public sealed class Check { public string Id; // "a", "b", "c", "d" public string Name; public bool Passed; public string Detail; public override string ToString() => $"[{(Passed ? "PASS" : "FAIL")}] ({Id}) {Name} — {Detail}"; } /// /// Compare two float fields for BIT equality. Returns the number of differing cells and the /// first difference found, so a failure is actionable rather than just red. /// public static (long differing, string firstDiff) CompareBitwise(float[,] a, float[,] b, int mapSize) { long differing = 0; string first = null; for (int x = 0; x < mapSize; x++) { for (int y = 0; y < mapSize; y++) { int ba = BitConverter.SingleToInt32Bits(a[x, y]); int bb = BitConverter.SingleToInt32Bits(b[x, y]); if (ba == bb) continue; differing++; first ??= $"first at [{x},{y}]: {a[x, y]:G9} (0x{ba:X8}) vs {b[x, y]:G9} (0x{bb:X8})"; } } return (differing, first); } /// /// (a) REGRESSION — with the curve off, shaping must return the pass-1 field untouched. /// /// ⚠ This is the WEAKER, always-available half of check (a): it proves pass 2 is a no-op when /// gated off. The stronger half — that pass 1 ITSELF still matches Phase 1 byte-for-byte — is /// , which needs a Phase-1 `.f32` on disk. /// public static Check RegressionCurveOff(Pass1Result p1, Pass2Result offResult) { var c = new Check { Id = "a", Name = "regression: curve OFF == pass-1 output" }; if (offResult.CurveOn) { c.Passed = false; c.Detail = "the result handed in was generated with the curve ON — wrong variant."; return c; } var (differing, firstDiff) = CompareBitwise(p1.Height, offResult.Height, p1.MapSize); bool aliased = offResult.FieldsAreAliased; c.Passed = differing == 0; c.Detail = c.Passed ? $"bit-identical over {(long)p1.MapSize * p1.MapSize:N0} cells" + (aliased ? " (and the fields alias one array, as the reference did)" : "") : $"{differing:N0} cells differ — {firstDiff}"; return c; } /// /// (a′) REGRESSION against a Phase-1 `.f32` dump — the cross-run half. /// /// ⚠ A MISSING DUMP IS NOT A PASS. It is reported as INCONCLUSIVE and the caller says so; a /// check that silently succeeds when its input is absent is worse than no check, because it /// buys confidence that was never earned. /// public static Check RegressionAgainstDump(float[,] current, float[,] phase1Dump, int mapSize, string dumpPath) { var c = new Check { Id = "a′", Name = "regression: pass-1 == Phase-1 .f32 dump" }; if (phase1Dump == null) { c.Passed = false; c.Detail = $"INCONCLUSIVE — no readable Phase-1 dump at {dumpPath} for this seed/size. " + "Not counted as a pass; set ISLA_PHASE1_SOURCE to a batch that has one."; return c; } var (differing, firstDiff) = CompareBitwise(phase1Dump, current, mapSize); c.Passed = differing == 0; c.Detail = c.Passed ? $"bit-identical to {dumpPath} over {(long)mapSize * mapSize:N0} cells" : $"{differing:N0} cells differ from {dumpPath} — {firstDiff}"; return c; } /// /// (b) CLASSIFY FIDELITY — the classify field is the raw pre-curve field, bit-for-bit, with /// the curve on or off. /// /// This is the invariant every later phase's oracle rests on: biomes and water will classify /// from this field, so if it has drifted by even one ulp the "md5-identical across shaping /// changes" guarantee is gone before it is ever used. /// public static Check ClassifyFidelity(Pass1Result p1, Pass2Result p2) { var c = new Check { Id = "b", Name = "classify field == raw pass-1 field" }; var (differing, firstDiff) = CompareBitwise(p1.Height, p2.HeightClassify, p1.MapSize); c.Passed = differing == 0; c.Detail = c.Passed ? $"bit-identical over {(long)p1.MapSize * p1.MapSize:N0} cells (curve {(p2.CurveOn ? "ON" : "OFF")})" : $"{differing:N0} cells differ — {firstDiff}"; return c; } /// /// (c) MONOTONICITY — recorded rather than re-run. /// /// HeightCurve.AssertMonotonic THROWS on violation and is called inside /// , so reaching this code at all means the sweep passed. The /// check exists so the oracle table states it explicitly instead of leaving the strongest /// guarantee implicit in the absence of a crash. /// public static Check Monotonicity(Pass2Result p2) { var c = new Check { Id = "c", Name = "curve strictly monotonic (24-corner sweep)" }; if (!p2.CurveOn) { c.Passed = true; c.Detail = "curve off — nothing to prove (identity is trivially monotonic)."; return c; } string note = p2.Notes.Find(n => n.Contains("Monotonicity assertion passed")); c.Passed = note != null; c.Detail = note ?? "no monotonicity confirmation recorded — AssertMonotonic did not run."; return c; } /// /// (d) BAND SHARES — the realized land shares against the M3 targets. /// /// ⚠ MEASURED ON THE RAW (INPUT) DISTRIBUTION, because that is where the knots cut. The /// shares are exact by construction IF the quantile machinery is right — so this check is /// really a proof that measured what it claimed, which is the one /// thing the reference could never verify about its own knots. /// /// /// Allowed absolute deviation per band, in percentage points. The knots come from the SAME /// histogram, so agreement is limited only by in-bin interpolation — tenths of a point, not /// whole ones. /// public static Check BandShares(LandHistogram raw, CurveKnots k, double tolerancePercentagePoints) { var c = new Check { Id = "d", Name = "realized land band shares == 60/13/10/5/8/3/1 targets" }; double[] realized = RealizedShares(raw, k); double worst = 0.0; int worstBand = -1; for (int i = 0; i < realized.Length; i++) { double d = Math.Abs(realized[i] - CurveKnots.BandShareTargets[i]); if (d > worst) { worst = d; worstBand = i; } } c.Passed = worst <= tolerancePercentagePoints; c.Detail = $"worst band '{CurveKnots.BandNames[worstBand]}' off by {worst:F3} pp " + $"(tolerance {tolerancePercentagePoints:F2} pp); realized " + string.Join("/", Array.ConvertAll(realized, v => v.ToString("F2"))); return c; } /// /// The fraction of land, in percent, falling in each of the curve's seven INPUT bands. /// Band edges are sea, K1..K6, +∞. /// public static double[] RealizedShares(LandHistogram raw, CurveKnots k) { float[] edges = { raw.SeaLevel, k.K1, k.K2, k.K3, k.K4, k.K5, k.K6 }; var shares = new double[7]; for (int i = 0; i < 6; i++) shares[i] = raw.FractionBetween(edges[i], edges[i + 1]) * 100.0; shares[6] = Math.Max(0.0, (1.0 - raw.FractionBelow(k.K6)) * 100.0); return shares; } /// /// A bit-regression against any `.f32` dump, with the caller naming the check — the /// chat2/02 generalization of , used to hold the /// staircase mode against task 01's own batch output. The same rule applies: a missing dump /// is INCONCLUSIVE and counted as a failure, never as a pass. /// public static Check DumpRegression(string id, string name, float[,] current, float[,] dump, int mapSize, string dumpPath) { var c = new Check { Id = id, Name = name }; if (dump == null) { c.Passed = false; c.Detail = $"INCONCLUSIVE — no readable dump at {dumpPath} for this seed/size. Not counted as a pass."; return c; } var (differing, firstDiff) = CompareBitwise(dump, current, mapSize); c.Passed = differing == 0; c.Detail = c.Passed ? $"bit-identical to {dumpPath} over {(long)mapSize * mapSize:N0} cells" : $"{differing:N0} cells differ from {dumpPath} — {firstDiff}"; return c; } /// /// (d) ⭐ LOWLANDS PRESERVED — the rev-3 task's load-bearing check. For every cell whose RAW /// height is at or below K2 (the toe+red band the developer likes), the continuous mode's /// output must be BIT-IDENTICAL to the staircase's. This mechanically enforces "do not lift /// the lowlands": the low pile cannot move if its every column is the same float. /// /// The identity is by construction — the continuous curve DELEGATES to the staircase's own /// toe+red code path below K2 — and this check is what keeps that construction honest /// against refactoring, float re-association, or a ceiling knob bug. /// /// ⚠ Run against the LIFTED_WRONG bookend this check is EXPECTED to fail — the caller /// reports that failure as confirmation of the wrong direction, not as a defect. /// public static Check LowlandsPreserved(Pass1Result p1, Pass2Result staircase, Pass2Result variant) { var c = new Check { Id = "d", Name = $"lowlands (raw ≤ K2) bit-identical to staircase [{variant.VariantLabel}]" }; float k2 = staircase.Knots.K2; long compared = 0, differing = 0; string first = null; for (int x = 0; x < p1.MapSize; x++) { for (int y = 0; y < p1.MapSize; y++) { if (p1.Height[x, y] > k2) continue; compared++; int ba = BitConverter.SingleToInt32Bits(staircase.Height[x, y]); int bb = BitConverter.SingleToInt32Bits(variant.Height[x, y]); if (ba == bb) continue; differing++; first ??= $"first at [{x},{y}] raw {p1.Height[x, y]:G9}: " + $"staircase {staircase.Height[x, y]:G9} vs {variant.Height[x, y]:G9}"; } } c.Passed = differing == 0; c.Detail = c.Passed ? $"bit-identical over all {compared:N0} lowland cells (raw ≤ K2 = {k2:F6})" : $"{differing:N0} of {compared:N0} lowland cells differ — {first}"; return c; } /// /// (e) UPPER CONTINUOUS — sample the climb's slope densely; no near-flat anywhere (a bench /// reborn), no cliff below the summit onset (the summit itself may steepen — that is the /// dramatic peak). ⚠ SOFT: this is an exploration batch, so a violation warns loudly and /// lands in the report rather than failing the run — the tool decides the exit code. /// public static Check UpperClimbProfile(Pass2Result p2) { var c = new Check { Id = "e", Name = $"upper climb continuous — no flats, no low-mid cliffs [{p2.VariantLabel}]" }; if (p2.Continuous == null) { c.Passed = false; c.Detail = "no continuous spline on this result — wrong mode handed in."; return c; } var (minN, minAt, maxN, maxAt, nearFlat, cliff) = p2.Continuous.SampleClimbSlopes(); c.Passed = !nearFlat && !cliff; c.Detail = $"slope (normalized, 1 = climb average): min {minN:F3} at raw {minAt:F4}" + $"{(nearFlat ? " ⚠ NEAR-FLAT (a bench reborn)" : "")}, " + $"max below onset {maxN:F3} at raw {maxAt:F4}" + $"{(cliff ? " ⚠ CLIFF below the summit onset" : "")}"; return c; } /// /// (f) SEA IDENTITY — per cell, not per count: a column is land in the variant exactly when /// it is land with the curve off. Counting alone could hide two errors that cancel; this /// cannot. The coastline is the one thing every mode, including the wrong one, must keep. /// public static Check SeaIdentity(Pass2Result off, Pass2Result variant, float seaLevel) { var c = new Check { Id = "f", Name = $"sea identity — per-cell landness unchanged [{variant.VariantLabel}]" }; long mismatches = 0; string first = null; for (int x = 0; x < off.MapSize; x++) { for (int y = 0; y < off.MapSize; y++) { bool a = off.Height[x, y] >= seaLevel; bool b = variant.Height[x, y] >= seaLevel; if (a == b) continue; mismatches++; first ??= $"first at [{x},{y}]: off {off.Height[x, y]:G9} vs {variant.Height[x, y]:G9}"; } } c.Passed = mismatches == 0; c.Detail = c.Passed ? $"per-cell landness identical over {(long)off.MapSize * off.MapSize:N0} cells" : $"{mismatches:N0} cells changed sides of the waterline — {first}"; return c; } /// /// (g) ⭐ MOUNTAIN RESTORED (chat2/03) — how much land ends up above 100 m and 220 m, /// against the staircase's own figures. /// /// ═══ ⚠ REPORTED, NOT GATED ═══ /// /// This is a taste target the developer tunes, so a miss is a FINDING, not a build failure — /// gating it would make `mountainLift` unusable as a knob, since every value but one would /// fail the run. What it must never do is stay silent: chat2/02 lost two thirds of the /// mountain and only found out because someone went looking at the dumps afterwards. This /// check is that look, made automatic. /// /// only decides whether the row reads PASS or NOTE; the /// numbers are always printed. /// public static Check MountainRestored(Pass2Result variant, Pass2Result staircase, float seaLevel, double tolerancePp) { var c = new Check { Id = "g", Name = $"mountain restored vs staircase [{variant.VariantLabel}]" }; var (v100, v220) = LandAbove(variant, seaLevel); var (s100, s220) = LandAbove(staircase, seaLevel); double d100 = v100 - s100; c.Passed = Math.Abs(d100) <= tolerancePp; c.Detail = $">100 m: {v100:F2} % vs staircase {s100:F2} % ({d100:+0.00;-0.00} pp) · " + $">220 m: {v220:F2} % vs {s220:F2} % ({v220 - s220:+0.00;-0.00} pp)"; return c; } /// Percentage of LAND above 100 m and 220 m of world height. Land = at/above sea. public static (double above100, double above220) LandAbove(Pass2Result p2, float seaLevel) { float t100 = seaLevel + WorldScale.RawFromMetres(100f); float t220 = seaLevel + WorldScale.RawFromMetres(220f); long land = 0, a100 = 0, a220 = 0; for (int x = 0; x < p2.MapSize; x++) { for (int y = 0; y < p2.MapSize; y++) { float h = p2.Height[x, y]; if (h < seaLevel) continue; land++; if (h > t100) a100++; if (h > t220) a220++; } } return land == 0 ? (0.0, 0.0) : (100.0 * a100 / land, 100.0 * a220 / land); } // ═══ chat2/05 — the offshore checks ═══ // (h) was the chat2/05 seeded-floor check — reverted out with the floor in chat2/06. No // count is guaranteed any more, so there is nothing for an oracle to assert; the count // table is the evidence, and it is statistics, not a check. /// /// (i) ⭐ MOAT INTACT — no offshore island is 8-connected to mainland land. The moat exists /// to make a land bridge impossible; this is the proof that it did. /// public static Check MoatIntact(Pass1Result p1, List comps) { var c = new Check { Id = "i", Name = "moat intact — no island touches the mainland" }; int bridged = OffshoreAnalysis.BridgedCount(comps); c.Passed = p1.HasIslandTag && bridged == 0; c.Detail = !p1.HasIslandTag ? "no island tag — nothing to check" : bridged == 0 ? $"all {comps.Count} islands are separated from mainland by water" : $"{bridged} island(s) BRIDGE to mainland land"; return c; } /// /// (j) ⭐ MAINLAND UNMOVED — with offshore on vs off, every cell that was LAND with it off is /// BIT-IDENTICAL with it on. The shelf touches only below-sea cells, the islets only lift /// below-sea cells; neither may touch existing land. (The falloff test and the moat did their /// job if this holds.) Reports how many sea cells the shelf moved and how many were lifted. /// public static Check MainlandUnmoved(Pass1Result off, Pass1Result on, float sea) { var c = new Check { Id = "j", Name = "mainland unmoved — every offshore-OFF land cell bit-identical with offshore ON" }; long land = 0, landDiff = 0, seaChanged = 0, lifted = 0; string first = null; for (int x = 0; x < off.MapSize; x++) { for (int y = 0; y < off.MapSize; y++) { float a = off.Height[x, y], b = on.Height[x, y]; if (a >= sea) { land++; if (BitConverter.SingleToInt32Bits(a) != BitConverter.SingleToInt32Bits(b)) { landDiff++; first ??= $"first at [{x},{y}]: {a:G9} → {b:G9}"; } } else { if (a != b) seaChanged++; if (b >= sea) lifted++; } } } c.Passed = landDiff == 0; c.Detail = landDiff == 0 ? $"all {land:N0} land cells bit-identical; {seaChanged:N0} sea cells remapped by the shelf, {lifted:N0} lifted to land" : $"{landDiff:N0} of {land:N0} land cells CHANGED — {first}"; return c; } /// /// (k) TAG ↔ COASTLINE CONSISTENT — per cell, classify-land ⇔ render-land (the curve is /// identity at sea and monotone above, so it must be — D-046), and every TAGGED cell is land /// in both fields. This is what lets the tag be carried through pass 2 without recomputation. /// public static Check TagCoastlineConsistent(Pass2Result p2, float sea) { var c = new Check { Id = "k", Name = "offshore tag: classify/render coastline consistent, every tagged cell is land" }; long mismatch = 0, tagNotLand = 0, tagged = 0; for (int x = 0; x < p2.MapSize; x++) { for (int y = 0; y < p2.MapSize; y++) { bool cl = p2.HeightClassify[x, y] >= sea; bool rl = p2.Height[x, y] >= sea; if (cl != rl) mismatch++; if (p2.IsIsland != null && p2.IsIsland[x, y]) { tagged++; if (!cl || !rl) tagNotLand++; } } } c.Passed = mismatch == 0 && tagNotLand == 0; c.Detail = $"{mismatch:N0} classify/render landness mismatches; {tagNotLand:N0} of {tagged:N0} tagged cells not land"; return c; } /// /// (l) HMaxSeed RECOMPUTED AFTER SHELF + OFFSHORE — reported. Expected unchanged (a ~34 m /// crest vs a ~290 m peak), but the ORDER is the fix (chat2/00 Drift §2), and the value is /// measured rather than assumed. Always passes; the detail is the point. /// public static Check HMaxAfterOffshore(Pass1Result p1) { bool moved = p1.HMaxSeed != p1.HMaxSeedBeforeOffshore; return new Check { Id = "l", Name = "HMaxSeed recomputed after shelf + offshore", Passed = true, Detail = $"before {p1.HMaxSeedBeforeOffshore:F6} → after {p1.HMaxSeed:F6} " + (moved ? "— ⚠ MOVED (an islet outran the peak?)" : "— unchanged, as expected; the ORDER is now right by construction"), }; } // ═══ chat2/07 — the region checks ═══ /// (m) CENTRE IS LAND — the mainland definition held (the massif is centred); the fallback was not needed. public static Check CentreIsLand(Pass1Result p1) { var c = new Check { Id = "m", Name = "mainland = centre component (centre cell is land, no fallback)" }; if (p1.Regions == null) { c.Passed = false; c.Detail = "no region labeling on this field"; return c; } var m = p1.Regions.Mainland; c.Passed = p1.Regions.CentreWasLand && m != null && (p1.RegionLedger == null || p1.RegionLedger.CentreWasLandPre); c.Detail = c.Passed ? $"centre is land; mainland id {p1.Regions.MainlandId}, {m.SizeCells:N0} cells, centroid ({m.CentroidX:F0},{m.CentroidY:F0}); {p1.Regions.IslandCount} islands" : "⚠ CENTRE CELL IS NOT LAND — fell back to the largest component"; return c; } /// /// (n) ⭐ THE REVERT GUARDS, RE-PROVEN ON THE FIELDS — filter OFF vs ON: every cell of the OFF /// field's MAINLAND component is bit-identical; every cell that changed was land in a /// sub-threshold NON-MAINLAND component of the OFF labeling (component-only) and went DOWN, to /// below sea (lower-only); nothing else moved. The pass asserted this as it ran; this is the /// independent proof on the finished fields. /// public static Check RevertGuards(Pass1Result off, Pass1Result on, float sea, long thresholdCells) { var c = new Check { Id = "n", Name = "speck revert: mainland bit-identical, every change is in a sub-threshold island and lower-only" }; if (off.Regions == null) { c.Passed = false; c.Detail = "the OFF field has no region labeling"; return c; } int n = off.MapSize; var lab = off.Regions; long mainland = 0, mainlandDiff = 0, changed = 0, notIsland = 0, notSmall = 0, raised = 0, notSea = 0; string first = null; for (int x = 0; x < n; x++) { for (int y = 0; y < n; y++) { float a = off.Height[x, y], b = on.Height[x, y]; int id = lab.Id[x * n + y]; bool isMain = id != 0 && id == lab.MainlandId; if (isMain) mainland++; if (BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b)) continue; changed++; if (isMain) { mainlandDiff++; first ??= $"mainland cell [{x},{y}] {a:G9} → {b:G9}"; continue; } if (id == 0) { notIsland++; first ??= $"sea cell [{x},{y}] changed {a:G9} → {b:G9}"; continue; } if (lab.Regions[id - 1].SizeCells >= thresholdCells) { notSmall++; first ??= $"cell [{x},{y}] of component {id} ({lab.Regions[id - 1].SizeCells} cells ≥ {thresholdCells}) changed"; } if (b >= a) { raised++; first ??= $"cell [{x},{y}] RAISED {a:G9} → {b:G9}"; } if (b >= sea) { notSea++; first ??= $"cell [{x},{y}] still land after revert ({b:G9})"; } } } c.Passed = mainlandDiff == 0 && notIsland == 0 && notSmall == 0 && raised == 0 && notSea == 0; c.Detail = c.Passed ? $"all {mainland:N0} mainland cells bit-identical; {changed:N0} cells changed, every one in a sub-threshold island, lowered below sea" : $"VIOLATION — mainland {mainlandDiff:N0} / non-island {notIsland:N0} / over-threshold {notSmall:N0} / raised {raised:N0} / still land {notSea:N0} — {first}"; return c; } /// (o) LABELS DETERMINISTIC — two generations of the same seed: id maps and component tables identical. public static Check LabelsDeterministic(Pass1Result a, Pass1Result b) { var c = new Check { Id = "o", Name = "region ids deterministic per seed (two runs, id map + table identical)" }; if (a.Regions == null || b.Regions == null) { c.Passed = false; c.Detail = "no region labeling"; return c; } long diff = 0; int n = a.MapSize; for (int i = 0; i < n * n; i++) if (a.Regions.Id[i] != b.Regions.Id[i]) diff++; bool table = a.Regions.Regions.Count == b.Regions.Regions.Count && a.Regions.MainlandId == b.Regions.MainlandId; if (table) for (int i = 0; i < a.Regions.Regions.Count; i++) { var ra = a.Regions.Regions[i]; var rb = b.Regions.Regions[i]; if (ra.SizeCells != rb.SizeCells || ra.CentroidX != rb.CentroidX || ra.CentroidY != rb.CentroidY || ra.Hemisphere != rb.Hemisphere || ra.IsMainland != rb.IsMainland) { table = false; break; } } c.Passed = diff == 0 && table; c.Detail = c.Passed ? $"{a.Regions.Regions.Count} components, id map identical over {(long)n * n:N0} cells, tables identical" : $"{diff:N0} id cells differ; tables {(table ? "identical" : "DIFFER")}"; return c; } /// Render the whole oracle as a markdown table for the INDEX and the report. public static string ToMarkdownTable(IEnumerable checks) { var sb = new System.Text.StringBuilder(); sb.AppendLine("| | Check | Result | Evidence |"); sb.AppendLine("|---|---|---|---|"); foreach (Check c in checks) sb.AppendLine($"| `{c.Id}` | {c.Name} | **{(c.Passed ? "PASS" : "FAIL")}** | {c.Detail} |"); return sb.ToString(); } } }