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:
28
docs/history/2026-04-07_normalizer-followups-generator.md
Normal file
28
docs/history/2026-04-07_normalizer-followups-generator.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# 2026-04-07 — normalizer follow-ups (Generator)
|
||||||
|
|
||||||
|
## 작업
|
||||||
|
normalizer PoC v2(#4, `05c7a3f`)에서 Evaluator가 비차단 risk로 남긴 두 항목을 구현.
|
||||||
|
|
||||||
|
- **Follow-up A** Float epsilon 구성화: `NormalizeProfile.float_decimals` (YAML, optional, default 6) → `Rules.RoundFloatsInNode(node, decimals)` 오버로드 → `Normalizer.Normalize`가 프로파일에서 읽어 주입.
|
||||||
|
- **Follow-up B** `mask_volatile_settings` JSON-path 스코핑: 필드명 HashSet → JSONPath-lite 화이트리스트(`$.a.b.c`). `Rules.ParseJsonPathLite`로 세그먼트 파싱, 정확 경로 매칭. 같은 이름의 무관한 서브트리 보호.
|
||||||
|
|
||||||
|
## 변경 파일
|
||||||
|
- `src/Recordingtest.Normalizer/Profile.cs` — `FloatDecimals`, `MaskVolatileSettings` 필드 추가. `IgnoreUnmatchedProperties()`.
|
||||||
|
- `src/Recordingtest.Normalizer/Rules.cs` — `RoundFloatsInNode(node, decimals)`, `MaskVolatileSettings(node, IReadOnlyList<string>)`, `ParseJsonPathLite`, `DefaultVolatileSettingPaths`.
|
||||||
|
- `src/Recordingtest.Normalizer/Normalizer.cs` — `round_floats`/`mask_volatile_settings` 케이스에서 프로파일 옵션 전달.
|
||||||
|
- `src/Recordingtest.Normalizer/profiles/default.yaml` — `float_decimals: 6` + 16개 `$.<path>` 항목.
|
||||||
|
- `tests/Recordingtest.Normalizer.Tests/RuleTests.cs` — 테스트 6개 추가.
|
||||||
|
|
||||||
|
## 결과
|
||||||
|
- Build: 0 warnings, 0 errors (TreatWarningsAsErrors).
|
||||||
|
- Normalizer tests: 10 → 16 (+6 신규, 모두 green).
|
||||||
|
- 솔루션 전체: 77 passed / 0 failed.
|
||||||
|
|
||||||
|
## Regression trap (Follow-up B)
|
||||||
|
`MaskVolatileSettings_SameNameInUnrelatedSubtree_NotMasked`는 `{GridSnap, Foo:{GridSnap}}` 입력에 `$.GridSnap` 화이트리스트를 적용. 수정 전 코드는 이름 기반 HashSet으로 `Foo.GridSnap`까지 마스킹했을 것이고 테스트가 실패했을 것이다. 신규 path 매칭은 stack 깊이/세그먼트가 정확히 일치할 때만 마스킹하므로 root 만 변경되고 nested boolean은 보존됨.
|
||||||
|
|
||||||
|
## 메타
|
||||||
|
- 소요 시간: 약 25분
|
||||||
|
- Context 사용량: 약 47k tokens (단일 세션)
|
||||||
|
- 관련 이슈: #2 (normalizer follow-ups), #4 후속
|
||||||
|
- 마커: non-issue / follow-up only — Sprint Contract DoD 변경 없음, PROGRESS/PLAN은 Evaluator/handoff에서 갱신.
|
||||||
@@ -92,7 +92,8 @@ public static class Normalizer
|
|||||||
{
|
{
|
||||||
if (isJson && jsonNode is not null)
|
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;
|
jsonNode = n;
|
||||||
log.Add(new RuleApplication(rule, c));
|
log.Add(new RuleApplication(rule, c));
|
||||||
}
|
}
|
||||||
@@ -106,7 +107,10 @@ public static class Normalizer
|
|||||||
{
|
{
|
||||||
if (isJson && jsonNode is not null)
|
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;
|
jsonNode = n;
|
||||||
log.Add(new RuleApplication(rule, c));
|
log.Add(new RuleApplication(rule, c));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ public sealed class Profile
|
|||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public List<string> Rules { get; set; } = new();
|
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)
|
public static Profile Load(string profileName)
|
||||||
{
|
{
|
||||||
var baseDir = AppContext.BaseDirectory;
|
var baseDir = AppContext.BaseDirectory;
|
||||||
@@ -22,6 +35,7 @@ public sealed class Profile
|
|||||||
var yaml = File.ReadAllText(path);
|
var yaml = File.ReadAllText(path);
|
||||||
var deserializer = new DeserializerBuilder()
|
var deserializer = new DeserializerBuilder()
|
||||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||||
|
.IgnoreUnmatchedProperties()
|
||||||
.Build();
|
.Build();
|
||||||
return deserializer.Deserialize<Profile>(yaml) ?? new Profile { Name = profileName };
|
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.
|
/// 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.
|
/// Returns (json-output, count) when input is JSON; otherwise returns input unchanged with count=0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
public const int DefaultFloatDecimals = 6;
|
||||||
|
|
||||||
public static (JsonNode? node, int count) RoundFloatsInNode(JsonNode? node)
|
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;
|
int count = 0;
|
||||||
if (node is null) return (null, 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)
|
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);
|
obj[kv.Key] = JsonValue.Create(rounded);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
@@ -126,7 +131,7 @@ public static class Rules
|
|||||||
var item = arr[i];
|
var item = arr[i];
|
||||||
if (item is JsonValue v && TryAsDouble(v, out var d, out var wasFloat) && wasFloat)
|
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);
|
arr[i] = JsonValue.Create(rounded);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
@@ -164,45 +169,104 @@ public static class Rules
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Allowlist of field names whose values are known to be volatile boolean/scalar
|
/// Default JSON-path allowlist for known volatile fields, used when a profile
|
||||||
/// settings (per docs/sut-catalog/json-configs.json). The values are replaced with
|
/// does not specify its own list. Each entry is a JSONPath-lite string anchored
|
||||||
/// a deterministic placeholder so golden-file comparisons stay stable while still
|
/// at the document root.
|
||||||
/// preserving the field's presence and key order.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly HashSet<string> VolatileSettingFieldNames = new(StringComparer.Ordinal)
|
public static readonly IReadOnlyList<string> DefaultVolatileSettingPaths = new List<string>
|
||||||
{
|
{
|
||||||
"CanOverrideWireColorWithFace",
|
"$.CanOverrideWireColorWithFace",
|
||||||
"IsSidePanelVisible",
|
"$.IsSidePanelVisible",
|
||||||
"OverrideFaceColor",
|
"$.OverrideFaceColor",
|
||||||
"Solar_IsLocalTime",
|
"$.Solar_IsLocalTime",
|
||||||
"VisibleGrid",
|
"$.VisibleGrid",
|
||||||
"GridSnap",
|
"$.GridSnap",
|
||||||
"MidpointOsnap",
|
"$.MidpointOsnap",
|
||||||
"GridSpacing",
|
"$.GridSpacing",
|
||||||
"GridColor.ALPHA",
|
"$.GridColor.ALPHA",
|
||||||
"GridColor.BLUE",
|
"$.GridColor.BLUE",
|
||||||
"GridColor.GREEN",
|
"$.GridColor.GREEN",
|
||||||
"GridColor.RED",
|
"$.GridColor.RED",
|
||||||
"MajorGridColor.ALPHA",
|
"$.MajorGridColor.ALPHA",
|
||||||
"MajorGridColor.BLUE",
|
"$.MajorGridColor.BLUE",
|
||||||
"MajorGridColor.GREEN",
|
"$.MajorGridColor.GREEN",
|
||||||
"MajorGridColor.RED",
|
"$.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)
|
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;
|
int count = 0;
|
||||||
if (node is null) return (null, 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);
|
Walk(node);
|
||||||
return (node, count);
|
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)
|
void Walk(JsonNode n)
|
||||||
{
|
{
|
||||||
if (n is JsonObject obj)
|
if (n is JsonObject obj)
|
||||||
{
|
{
|
||||||
foreach (var kv in obj.ToList())
|
foreach (var kv in obj.ToList())
|
||||||
{
|
{
|
||||||
if (VolatileSettingFieldNames.Contains(kv.Key))
|
stack.Add(kv.Key);
|
||||||
|
if (PathMatches())
|
||||||
{
|
{
|
||||||
obj[kv.Key] = JsonValue.Create("<VOLATILE>");
|
obj[kv.Key] = JsonValue.Create("<VOLATILE>");
|
||||||
count++;
|
count++;
|
||||||
@@ -211,6 +275,7 @@ public static class Rules
|
|||||||
{
|
{
|
||||||
Walk(kv.Value);
|
Walk(kv.Value);
|
||||||
}
|
}
|
||||||
|
stack.RemoveAt(stack.Count - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (n is JsonArray arr)
|
else if (n is JsonArray arr)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
name: default
|
name: default
|
||||||
|
float_decimals: 6
|
||||||
rules:
|
rules:
|
||||||
- strip_timestamps
|
- strip_timestamps
|
||||||
- mask_guids
|
- mask_guids
|
||||||
@@ -6,3 +7,20 @@ rules:
|
|||||||
- round_floats
|
- round_floats
|
||||||
- mask_volatile_settings
|
- mask_volatile_settings
|
||||||
- sort_json_keys
|
- 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"
|
||||||
|
|||||||
@@ -109,6 +109,89 @@ public class RuleTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundFloats_DefaultProfile_Rounds6Decimals()
|
||||||
|
{
|
||||||
|
var input = "{\"x\": 3.1415926535897932}";
|
||||||
|
var r = Normalizer.Normalize(input, "default");
|
||||||
|
var node = JsonNode.Parse(r.Output)!;
|
||||||
|
Assert.Equal(3.141593, node["x"]!.GetValue<double>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundFloats_ProfileWithDecimals3_RoundsTo3()
|
||||||
|
{
|
||||||
|
// Write a temp profile next to the loaded profiles dir.
|
||||||
|
var baseDir = AppContext.BaseDirectory;
|
||||||
|
var dest = Path.Combine(baseDir, "profiles", "test_decimals3.yaml");
|
||||||
|
File.WriteAllText(dest,
|
||||||
|
"name: test_decimals3\nfloat_decimals: 3\nrules:\n - round_floats\n");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var input = "{\"x\": 3.1415926535}";
|
||||||
|
var r = Normalizer.Normalize(input, "test_decimals3");
|
||||||
|
var node = JsonNode.Parse(r.Output)!;
|
||||||
|
Assert.Equal(3.142, node["x"]!.GetValue<double>());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(dest)) File.Delete(dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Profile_OmittedFloatDecimals_DefaultsTo6()
|
||||||
|
{
|
||||||
|
var baseDir = AppContext.BaseDirectory;
|
||||||
|
var dest = Path.Combine(baseDir, "profiles", "test_no_decimals.yaml");
|
||||||
|
File.WriteAllText(dest, "name: test_no_decimals\nrules:\n - round_floats\n");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var profile = Profile.Load("test_no_decimals");
|
||||||
|
Assert.Null(profile.FloatDecimals);
|
||||||
|
var input = "{\"x\": 3.1415926535897932}";
|
||||||
|
var r = Normalizer.Normalize(input, "test_no_decimals");
|
||||||
|
var node = JsonNode.Parse(r.Output)!;
|
||||||
|
Assert.Equal(3.141593, node["x"]!.GetValue<double>());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(dest)) File.Delete(dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaskVolatileSettings_RootField_Masks()
|
||||||
|
{
|
||||||
|
var node = JsonNode.Parse("{\"GridSnap\":true,\"Other\":1}");
|
||||||
|
var (n, c) = Rules.MaskVolatileSettings(node, new[] { "$.GridSnap" });
|
||||||
|
Assert.Equal(1, c);
|
||||||
|
Assert.Equal("<VOLATILE>", n!["GridSnap"]!.GetValue<string>());
|
||||||
|
Assert.Equal(1, n!["Other"]!.GetValue<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaskVolatileSettings_SameNameInUnrelatedSubtree_NotMasked()
|
||||||
|
{
|
||||||
|
// Root has GridSnap (should mask), and an unrelated subtree Foo.GridSnap (should NOT mask).
|
||||||
|
var node = JsonNode.Parse("{\"GridSnap\":true,\"Foo\":{\"GridSnap\":false}}");
|
||||||
|
var (n, c) = Rules.MaskVolatileSettings(node, new[] { "$.GridSnap" });
|
||||||
|
Assert.Equal(1, c);
|
||||||
|
Assert.Equal("<VOLATILE>", n!["GridSnap"]!.GetValue<string>());
|
||||||
|
// Unrelated subtree must remain its original boolean value.
|
||||||
|
Assert.False(n!["Foo"]!["GridSnap"]!.GetValue<bool>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaskVolatileSettings_NestedPath_MasksCorrectly()
|
||||||
|
{
|
||||||
|
var node = JsonNode.Parse("{\"GridColor\":{\"R\":12,\"G\":34}}");
|
||||||
|
var (n, c) = Rules.MaskVolatileSettings(node, new[] { "$.GridColor.R" });
|
||||||
|
Assert.Equal(1, c);
|
||||||
|
Assert.Equal("<VOLATILE>", n!["GridColor"]!["R"]!.GetValue<string>());
|
||||||
|
Assert.Equal(34, n!["GridColor"]!["G"]!.GetValue<int>());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Normalize_SidecarPath_AcceptsDirectory()
|
public void Normalize_SidecarPath_AcceptsDirectory()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user