diff --git a/CDTk.slnx b/CDTk.slnx index f1d8029..22238ce 100644 --- a/CDTk.slnx +++ b/CDTk.slnx @@ -1,3 +1,4 @@ + diff --git a/CSharp.CDTk.Tests/CSharp.CDTk.Tests.csproj b/CSharp.CDTk.Tests/CSharp.CDTk.Tests.csproj new file mode 100644 index 0000000..507ea26 --- /dev/null +++ b/CSharp.CDTk.Tests/CSharp.CDTk.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CSharp.CDTk.Tests/UnitTest1.cs b/CSharp.CDTk.Tests/UnitTest1.cs new file mode 100644 index 0000000..5b7ef54 --- /dev/null +++ b/CSharp.CDTk.Tests/UnitTest1.cs @@ -0,0 +1,85 @@ +using CDTk; +using System.Globalization; +using System.Linq; + +namespace CSharp.CDTk.Tests; + +public class TranslationDeterminismTests +{ + [Fact] + public void CompileSource_ProducesExpectedGoldenOutput() + { + var compiler = new Compiler(new MiniPythonGrammar(), new MiniJavaGrammar()); + + var output = new string(compiler.CompileSource("print(x)").ToChars()); + + Assert.Equal("System.out.println(x);", output); + } + + [Fact] + public void CompileSource_Replay_IsDeterministic() + { + var compiler = new Compiler(new MiniPythonGrammar(), new MiniJavaGrammar()); + + var first = new string(compiler.CompileSource("print(x)").ToChars()); + var second = new string(compiler.CompileSource("print(x)").ToChars()); + var third = new string(new Compiler(new MiniPythonGrammar(), new MiniJavaGrammar()).CompileSource("print(x)").ToChars()); + + Assert.Equal(first, second); + Assert.Equal(second, third); + } + + [Fact] + public void TranslationPlanBuilder_Rebuild_IsDeterministic() + { + var source = Translation.AnalyzeGrammar(new MiniPythonGrammar()); + var target = Translation.AnalyzeGrammar(new MiniJavaGrammar()); + var builder = new TranslationPlanBuilder(source, target); + + var first = builder.Build(); + var second = builder.Build(); + + var firstMappings = first.SelectedMappings + .OrderBy(p => p.Key, StringComparer.Ordinal) + .Select(p => $"{p.Key}:{p.Value.TargetRuleName}:{RenderMap(p.Value.ChildPositionMap)}") + .ToArray(); + var secondMappings = second.SelectedMappings + .OrderBy(p => p.Key, StringComparer.Ordinal) + .Select(p => $"{p.Key}:{p.Value.TargetRuleName}:{RenderMap(p.Value.ChildPositionMap)}") + .ToArray(); + + Assert.Equal(firstMappings, secondMappings); + } + + [Fact] + public void CompileSource_WithUnsupportedMapping_FailsDeterministically() + { + var compiler = new Compiler(new SingleTokenSourceGrammar(), new IncompatibleTargetGrammar()); + + var ex = Assert.Throws(() => compiler.CompileSource("x").ToChars()); + + Assert.Contains("Validation status", ex.Message, StringComparison.Ordinal); + Assert.Contains("Policy=strict-99.99-v2", ex.Message, StringComparison.Ordinal); + } + + private static string RenderMap(IReadOnlyDictionary map) + => string.Join(",", map.OrderBy(p => p.Key).Select(p => $"{p.Key.ToString(CultureInfo.InvariantCulture)}->{p.Value.ToString(CultureInfo.InvariantCulture)}")); + + private sealed class SingleTokenSourceGrammar : Grammar + { + public static readonly Rule Program = new(new Token(ToUnits("x"))); + + private static string[] ToUnits(string text) + => text.Select(ch => $"U+{((int)ch).ToString("X4", CultureInfo.InvariantCulture)}").ToArray(); + } + + private sealed class IncompatibleTargetGrammar : Grammar + { + private static readonly Rule A = new(new Token(ToUnits("a"))); + private static readonly Rule B = new(new Token(ToUnits("b"))); + public static readonly Rule Program = A + B; + + private static string[] ToUnits(string text) + => text.Select(ch => $"U+{((int)ch).ToString("X4", CultureInfo.InvariantCulture)}").ToArray(); + } +} diff --git a/CSharp.CDTk/Analysis/Analysis.cs b/CSharp.CDTk/Analysis/Analysis.cs index 923c58c..271755c 100644 --- a/CSharp.CDTk/Analysis/Analysis.cs +++ b/CSharp.CDTk/Analysis/Analysis.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; +using System.Text; namespace CDTk { @@ -149,10 +151,7 @@ internal CategoricalSemanticDiagram Parse(string input) int inputLength = span.Length; - var full = results - .Where(r => r.End == inputLength) - .OrderByDescending(r => r.End - r.Start) - .FirstOrDefault(); + var full = SelectDeterministicBestResult(results.Where(r => r.End == inputLength)); if (full == null) { @@ -169,7 +168,12 @@ internal CategoricalSemanticDiagram Parse(string input) // Infer a start rule: a non-token rule that is never referenced as a child private Rule InferStartRule() { - var all = _grammar.GetAllRules().Select(r => r.ResolvedRule).Distinct().ToList(); + var all = _grammar.GetAllRules() + .Select(r => r.ResolvedRule) + .Distinct() + .OrderBy(r => r.Name ?? string.Empty, StringComparer.Ordinal) + .ThenBy(r => r.Type.ToString(), StringComparer.Ordinal) + .ToList(); var allSet = new HashSet(all); var referenced = new HashSet(); @@ -775,10 +779,9 @@ private CsdNode BuildCsdFromParseNode(ParseNode pn, CategoricalSemanticDiagram d // Pick the child whose end position matches this node's end — that // selects the longest (correct) parse when multiple completions have // been accumulated on the same node due to GLL ambiguity. - var bestChild = pn.Children - .Where(c => c.End == pn.End) - .OrderByDescending(c => c.End - c.Start) - .FirstOrDefault() ?? pn.Children[0]; + var bestChild = SelectDeterministicBestNode(pn.Children.Where(c => c.End == pn.End)) + ?? SelectDeterministicBestNode(pn.Children) + ?? pn.Children[0]; var chosen = BuildCsdFromParseNode(bestChild, diagram); var name = rule.Name ?? "AnonymousChoice"; @@ -872,8 +875,14 @@ private static IEnumerable SelectSequenceChildren(ParseNode node) // Fallback: deduplicate by start position (take longest per start). return node.Children .GroupBy(c => c.Start) - .Select(g => g.OrderByDescending(c => c.End - c.Start).First()) - .OrderBy(c => c.Start); + .Select(g => g + .OrderByDescending(c => c.End - c.Start) + .ThenBy(c => c.End) + .ThenBy(c => GetDeterministicNodeKey(c), StringComparer.Ordinal) + .First()) + .OrderBy(c => c.Start) + .ThenBy(c => c.End) + .ThenBy(c => GetDeterministicNodeKey(c), StringComparer.Ordinal); } private static string GetCategoryOfNode(CsdNode node) @@ -887,6 +896,54 @@ private static string GetCategoryOfNode(CsdNode node) _ => "Unknown" }; } + + private static ParseResult? SelectDeterministicBestResult(IEnumerable results) + { + return results + .OrderByDescending(r => r.End - r.Start) + .ThenBy(r => r.Start) + .ThenBy(r => r.End) + .ThenBy(r => GetDeterministicNodeKey(r.Node), StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static ParseNode? SelectDeterministicBestNode(IEnumerable nodes) + { + return nodes + .OrderByDescending(n => n.End - n.Start) + .ThenBy(n => n.Start) + .ThenBy(n => n.End) + .ThenBy(n => GetDeterministicNodeKey(n), StringComparer.Ordinal) + .FirstOrDefault(); + } + + private static string GetDeterministicNodeKey(ParseNode node) + { + var builder = new StringBuilder(); + AppendDeterministicNodeKey(node, builder); + return builder.ToString(); + } + + private static void AppendDeterministicNodeKey(ParseNode node, StringBuilder builder) + { + builder.Append(node.Rule.Name ?? string.Empty); + builder.Append('|'); + builder.Append(node.Rule.Type.ToString()); + builder.Append('|'); + builder.Append(node.Start.ToString(CultureInfo.InvariantCulture)); + builder.Append('|'); + builder.Append(node.End.ToString(CultureInfo.InvariantCulture)); + builder.Append('|'); + builder.Append(node.Text ?? string.Empty); + builder.Append('|'); + builder.Append(node.Children.Count.ToString(CultureInfo.InvariantCulture)); + foreach (var child in node.Children.OrderBy(c => c.Start).ThenBy(c => c.End).ThenBy(c => c.Rule.Name ?? string.Empty, StringComparer.Ordinal)) + { + builder.Append('{'); + AppendDeterministicNodeKey(child, builder); + builder.Append('}'); + } + } } public sealed class GrammarCsdBuilder diff --git a/CSharp.CDTk/CDTk.cs b/CSharp.CDTk/CDTk.cs index 1f1f5a0..7f1b8b4 100644 --- a/CSharp.CDTk/CDTk.cs +++ b/CSharp.CDTk/CDTk.cs @@ -695,7 +695,7 @@ private void AssignRuleNames() BindingFlags.Static | BindingFlags.FlattenHierarchy); - foreach (var field in fields) + foreach (var field in fields.OrderBy(f => f.Name, StringComparer.Ordinal)) { if (field.FieldType == typeof(Rule)) { @@ -713,7 +713,7 @@ internal IEnumerable GetAllRules() BindingFlags.Static | BindingFlags.FlattenHierarchy); - foreach (var f in fields) + foreach (var f in fields.OrderBy(field => field.Name, StringComparer.Ordinal)) { if (f.FieldType == typeof(Rule)) { @@ -856,6 +856,8 @@ private CompilationSnapshot Execute() .UseSemantics(semantics); translation = translator.TranslateDetailed(sourceDiagram.Root); + var replayTranslation = translator.TranslateDetailed(sourceDiagram.Root); + var reproducibleTranslationOutput = AreExecutionOutputsEquivalent(translation, replayTranslation); Exception? targetParseException = null; var roundTripStable = true; try @@ -890,7 +892,17 @@ private CompilationSnapshot Execute() roundTripStable = false; } - assessment = TranslationReliabilityEngine.Assess(plan, translation, sourceDiagram, targetDiagram, targetParseException, semantics, policy, roundTripStable, reproduciblePlanSelection); + assessment = TranslationReliabilityEngine.Assess( + plan, + translation, + sourceDiagram, + targetDiagram, + targetParseException, + semantics, + policy, + roundTripStable, + reproduciblePlanSelection, + reproducibleTranslationOutput); if (assessment.IsReliable) break; @@ -903,7 +915,7 @@ private CompilationSnapshot Execute() var failure = assessment == null || assessment.Violations.Count == 0 ? "Translation reliability gate failed." : string.Join(" ", assessment.Violations); - throw new InvalidOperationException(failure); + throw new InvalidOperationException($"{failure} Policy={policy.PolicyVersion}."); } _cached = new CompilationSnapshot(translation.Text, targetDiagram); @@ -933,6 +945,30 @@ private static bool ArePlanSelectionsEquivalent(StructuralTranslationPlan first, return true; } + private static bool AreExecutionOutputsEquivalent(TranslationExecutionResult first, TranslationExecutionResult second) + { + if (!string.Equals(first.Text, second.Text, StringComparison.Ordinal)) + return false; + if (first.Diagnostics.Count != second.Diagnostics.Count) + return false; + + for (var i = 0; i < first.Diagnostics.Count; i++) + { + var left = first.Diagnostics[i]; + var right = second.Diagnostics[i]; + if (left.Severity != right.Severity || + !string.Equals(left.Code, right.Code, StringComparison.Ordinal) || + !string.Equals(left.Message, right.Message, StringComparison.Ordinal) || + !string.Equals(left.SourceRuleName, right.SourceRuleName, StringComparison.Ordinal) || + !string.Equals(left.TargetRuleName, right.TargetRuleName, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + private sealed class CompilationSnapshot { public CompilationSnapshot(string generatedText, CategoricalSemanticDiagram targetDiagram) diff --git a/CSharp.CDTk/Translation/Translation.cs b/CSharp.CDTk/Translation/Translation.cs index 3a83c48..f541b91 100644 --- a/CSharp.CDTk/Translation/Translation.cs +++ b/CSharp.CDTk/Translation/Translation.cs @@ -1287,7 +1287,7 @@ private int ScoreGlobalCandidate(RuleSignature sourceRule, RuleTranslationMappin private IEnumerable GenerateCandidates(RuleSignature sourceRule) { var candidates = new List(); - foreach (var targetRule in TargetSignature.Rules.Values) + foreach (var targetRule in TargetSignature.Rules.Values.OrderBy(rule => rule.Id, StringComparer.Ordinal)) { if (_exclusions.Contains((sourceRule.Id, targetRule.Id))) continue; @@ -1429,13 +1429,12 @@ private static IEnumerable ParseCounterexampleM } diagnostics.Add(new TranslationDiagnostic( - TranslationDiagnosticSeverity.Warning, - "unmapped-target-semantic-child-placeholder", - "Target semantic child could not be inferred from source structure. Placeholder segment inserted for partial mapping fallback.", + TranslationDiagnosticSeverity.Error, + "unmapped-target-semantic-child-unsupported", + $"Target semantic child at index {targetIndex.ToString(CultureInfo.InvariantCulture)} could not be inferred from source structure; candidate mapping is unsupported.", sourceRule.Id, targetRule.Id)); - template.Add(new LiteralTemplateSegment( - $"/*TODO:map:{targetRule.Id}:child:{targetIndex.ToString(CultureInfo.InvariantCulture)}*/")); + return null; } } @@ -2030,12 +2029,14 @@ public static SemanticBenchmarkMetrics Evaluate( } public sealed record TranslationReliabilityPolicy( + string PolicyVersion = "strict-99.99-v1", bool RequireNoUnsupportedMappings = true, bool RequireHighConfidenceMappings = true, bool FailOnTranslationErrors = true, bool RequireSemanticEquivalence = true, bool RequireRoundTripStability = true, bool RequireDeterministicSelection = true, + bool RequireDeterministicTranslationOutput = true, double MinCoverageRate = 1d, double MaxAmbiguityRate = 0.45d, double MinMeanConfidenceMargin = 0.08d, @@ -2044,12 +2045,14 @@ public sealed record TranslationReliabilityPolicy( int MaxRefinementIterations = 2) { public static TranslationReliabilityPolicy Strict99_99 { get; } = new( + PolicyVersion: "strict-99.99-v2", RequireNoUnsupportedMappings: true, RequireHighConfidenceMappings: false, FailOnTranslationErrors: true, RequireSemanticEquivalence: false, RequireRoundTripStability: true, RequireDeterministicSelection: true, + RequireDeterministicTranslationOutput: true, MinCoverageRate: 1d, MaxAmbiguityRate: 0.60d, MinMeanConfidenceMargin: 0d, @@ -2089,7 +2092,8 @@ public static TranslationReliabilityAssessment Assess( ITranslationSemantics? semantics, TranslationReliabilityPolicy policy, bool roundTripStable = true, - bool reproduciblePlanSelection = true) + bool reproduciblePlanSelection = true, + bool reproducibleTranslationOutput = true) { if (plan == null) throw new ArgumentNullException(nameof(plan)); @@ -2201,6 +2205,8 @@ public static TranslationReliabilityAssessment Assess( if (policy.RequireDeterministicSelection && !reproduciblePlanSelection) violations.Add("Plan selection reproducibility check failed."); + if (policy.RequireDeterministicTranslationOutput && !reproducibleTranslationOutput) + violations.Add("Translation output reproducibility check failed."); return new TranslationReliabilityAssessment( isReliable: violations.Count == 0,