normalizer: configurable float epsilon + JSON-path volatile mask scoping

Follow-ups to #4 normalizer PoC v2:
- Profile.float_decimals (default 6) flows into Rules.RoundFloatsInNode.
- mask_volatile_settings switches from name-only HashSet to a JSONPath-lite
  allowlist ($.a.b.c) so same-named fields in unrelated subtrees stay intact.
- default.yaml migrated; 6 new tests including a regression trap for the
  unrelated-subtree case. 16/16 normalizer tests, 77/77 solution tests.

Refs #2
This commit is contained in:
minsung
2026-04-07 20:42:27 +09:00
parent 0f0324efb5
commit eeee3c2a03
6 changed files with 238 additions and 26 deletions

View File

@@ -92,7 +92,8 @@ public static class Normalizer
{
if (isJson && jsonNode is not null)
{
var (n, c) = Rules.RoundFloatsInNode(jsonNode);
var decimals = profile.FloatDecimals ?? Rules.DefaultFloatDecimals;
var (n, c) = Rules.RoundFloatsInNode(jsonNode, decimals);
jsonNode = n;
log.Add(new RuleApplication(rule, c));
}
@@ -106,7 +107,10 @@ public static class Normalizer
{
if (isJson && jsonNode is not null)
{
var (n, c) = Rules.MaskVolatileSettings(jsonNode);
var paths = (profile.MaskVolatileSettings is { Count: > 0 })
? (IReadOnlyList<string>)profile.MaskVolatileSettings
: Rules.DefaultVolatileSettingPaths;
var (n, c) = Rules.MaskVolatileSettings(jsonNode, paths);
jsonNode = n;
log.Add(new RuleApplication(rule, c));
}

View File

@@ -8,6 +8,19 @@ public sealed class Profile
public string Name { get; set; } = "";
public List<string> Rules { get; set; } = new();
/// <summary>
/// Optional float decimals for round_floats. Null means use default (6).
/// </summary>
[YamlMember(Alias = "float_decimals", ApplyNamingConventions = false)]
public int? FloatDecimals { get; set; }
/// <summary>
/// Optional JSON-path allowlist for mask_volatile_settings.
/// Each entry is a JSONPath-lite string like "$.GridSnap" or "$.Viewport.GridColor.R".
/// </summary>
[YamlMember(Alias = "mask_volatile_settings", ApplyNamingConventions = false)]
public List<string>? MaskVolatileSettings { get; set; }
public static Profile Load(string profileName)
{
var baseDir = AppContext.BaseDirectory;
@@ -22,6 +35,7 @@ public sealed class Profile
var yaml = File.ReadAllText(path);
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
return deserializer.Deserialize<Profile>(yaml) ?? new Profile { Name = profileName };
}

View File

@@ -94,7 +94,12 @@ public static class Rules
/// JSON-aware: parse and round all double values to 6 decimals. Operates only when input is JSON.
/// Returns (json-output, count) when input is JSON; otherwise returns input unchanged with count=0.
/// </summary>
public const int DefaultFloatDecimals = 6;
public static (JsonNode? node, int count) RoundFloatsInNode(JsonNode? node)
=> RoundFloatsInNode(node, DefaultFloatDecimals);
public static (JsonNode? node, int count) RoundFloatsInNode(JsonNode? node, int decimals)
{
int count = 0;
if (node is null) return (null, 0);
@@ -109,7 +114,7 @@ public static class Rules
{
if (kv.Value is JsonValue v && TryAsDouble(v, out var d, out var wasFloat) && wasFloat)
{
var rounded = Math.Round(d, 6, MidpointRounding.AwayFromZero);
var rounded = Math.Round(d, decimals, MidpointRounding.AwayFromZero);
obj[kv.Key] = JsonValue.Create(rounded);
count++;
}
@@ -126,7 +131,7 @@ public static class Rules
var item = arr[i];
if (item is JsonValue v && TryAsDouble(v, out var d, out var wasFloat) && wasFloat)
{
var rounded = Math.Round(d, 6, MidpointRounding.AwayFromZero);
var rounded = Math.Round(d, decimals, MidpointRounding.AwayFromZero);
arr[i] = JsonValue.Create(rounded);
count++;
}
@@ -164,45 +169,104 @@ public static class Rules
}
/// <summary>
/// Allowlist of field names whose values are known to be volatile boolean/scalar
/// settings (per docs/sut-catalog/json-configs.json). The values are replaced with
/// a deterministic placeholder so golden-file comparisons stay stable while still
/// preserving the field's presence and key order.
/// Default JSON-path allowlist for known volatile fields, used when a profile
/// does not specify its own list. Each entry is a JSONPath-lite string anchored
/// at the document root.
/// </summary>
public static readonly HashSet<string> VolatileSettingFieldNames = new(StringComparer.Ordinal)
public static readonly IReadOnlyList<string> DefaultVolatileSettingPaths = new List<string>
{
"CanOverrideWireColorWithFace",
"IsSidePanelVisible",
"OverrideFaceColor",
"Solar_IsLocalTime",
"VisibleGrid",
"GridSnap",
"MidpointOsnap",
"GridSpacing",
"GridColor.ALPHA",
"GridColor.BLUE",
"GridColor.GREEN",
"GridColor.RED",
"MajorGridColor.ALPHA",
"MajorGridColor.BLUE",
"MajorGridColor.GREEN",
"MajorGridColor.RED",
"$.CanOverrideWireColorWithFace",
"$.IsSidePanelVisible",
"$.OverrideFaceColor",
"$.Solar_IsLocalTime",
"$.VisibleGrid",
"$.GridSnap",
"$.MidpointOsnap",
"$.GridSpacing",
"$.GridColor.ALPHA",
"$.GridColor.BLUE",
"$.GridColor.GREEN",
"$.GridColor.RED",
"$.MajorGridColor.ALPHA",
"$.MajorGridColor.BLUE",
"$.MajorGridColor.GREEN",
"$.MajorGridColor.RED",
};
/// <summary>
/// Parses a JSONPath-lite string of the form "$.a.b.c" into segment list ["a","b","c"].
/// Throws on malformed input. Wildcards and array indexers are not supported.
/// </summary>
public static List<string> ParseJsonPathLite(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("path is empty", nameof(path));
if (!path.StartsWith("$"))
throw new ArgumentException($"path must start with '$': {path}", nameof(path));
var segments = new List<string>();
var rest = path.Substring(1);
if (rest.Length == 0) return segments;
if (rest[0] != '.')
throw new ArgumentException($"path must continue with '.': {path}", nameof(path));
// split on '.' but preserve empties as errors
var parts = rest.Substring(1).Split('.');
foreach (var p in parts)
{
if (string.IsNullOrEmpty(p))
throw new ArgumentException($"empty segment in path: {path}", nameof(path));
if (p.Contains('*') || p.Contains('[') || p.Contains(']'))
throw new ArgumentException($"wildcards/indexers not supported: {path}", nameof(path));
segments.Add(p);
}
return segments;
}
public static (JsonNode? node, int count) MaskVolatileSettings(JsonNode? node)
=> MaskVolatileSettings(node, DefaultVolatileSettingPaths);
public static (JsonNode? node, int count) MaskVolatileSettings(JsonNode? node, IReadOnlyList<string> jsonPaths)
{
int count = 0;
if (node is null) return (null, 0);
// Pre-parse the allowlist into segment chains for exact matching.
var allow = new List<List<string>>(jsonPaths.Count);
foreach (var p in jsonPaths)
{
allow.Add(ParseJsonPathLite(p));
}
var stack = new List<string>();
Walk(node);
return (node, count);
bool PathMatches()
{
foreach (var chain in allow)
{
if (chain.Count != stack.Count) continue;
bool eq = true;
for (int i = 0; i < chain.Count; i++)
{
if (!string.Equals(chain[i], stack[i], StringComparison.Ordinal))
{
eq = false;
break;
}
}
if (eq) return true;
}
return false;
}
void Walk(JsonNode n)
{
if (n is JsonObject obj)
{
foreach (var kv in obj.ToList())
{
if (VolatileSettingFieldNames.Contains(kv.Key))
stack.Add(kv.Key);
if (PathMatches())
{
obj[kv.Key] = JsonValue.Create("<VOLATILE>");
count++;
@@ -211,6 +275,7 @@ public static class Rules
{
Walk(kv.Value);
}
stack.RemoveAt(stack.Count - 1);
}
}
else if (n is JsonArray arr)

View File

@@ -1,4 +1,5 @@
name: default
float_decimals: 6
rules:
- strip_timestamps
- mask_guids
@@ -6,3 +7,20 @@ rules:
- round_floats
- mask_volatile_settings
- sort_json_keys
mask_volatile_settings:
- "$.CanOverrideWireColorWithFace"
- "$.IsSidePanelVisible"
- "$.OverrideFaceColor"
- "$.Solar_IsLocalTime"
- "$.VisibleGrid"
- "$.GridSnap"
- "$.MidpointOsnap"
- "$.GridSpacing"
- "$.GridColor.ALPHA"
- "$.GridColor.BLUE"
- "$.GridColor.GREEN"
- "$.GridColor.RED"
- "$.MajorGridColor.ALPHA"
- "$.MajorGridColor.BLUE"
- "$.MajorGridColor.GREEN"
- "$.MajorGridColor.RED"