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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user