perf(templateengine): memoise CollisionDetector's composed-module walk per check (review 2026-10-21 04/P8b)

This commit is contained in:
Joseph Doherty
2026-10-22 06:30:27 -04:00
parent 52aab37299
commit 0d4f7d33b9
3 changed files with 153 additions and 26 deletions
@@ -32,6 +32,7 @@ public static class CollisionDetector
// not-yet-saved templates carrying Id 0).
var lookup = CycleDetector.BuildLookup(allTemplates);
var allMembers = new List<ResolvedMember>();
var moduleMemo = new Dictionary<int, IReadOnlyList<RelativeMember>>();
// Collect direct (top-level) members. Inherited placeholder rows are skipped:
// the inheritance walk below re-adds them under the parent's origin.
@@ -47,12 +48,12 @@ public static class CollisionDetector
prefix: composition.InstanceName,
lookup,
allMembers,
visited: new HashSet<int>());
moduleMemo);
}
}
// Collect inherited members (walk parent chain)
CollectInheritedMembers(template, lookup, allMembers, new HashSet<int> { template.Id });
CollectInheritedMembers(template, lookup, allMembers, moduleMemo, new HashSet<int> { template.Id });
// Detect duplicates by canonical name
var collisions = new List<string>();
@@ -83,9 +84,9 @@ public static class CollisionDetector
/// <see cref="CollectInheritedMembers"/>. Counting both yielded two distinct
/// origins for the same canonical name and a spurious collision, which blocked
/// every attempt to add an attribute/composition to a derived template.
/// Pass <c>false</c> only for the composed-module walk, where
/// placeholder rows are the sole representation of a derived module's inherited
/// members (that walk does not climb the composed template's parent chain).
/// (The composed-module walk, <see cref="GetModuleMembers"/>, keeps placeholder rows
/// instead: there they are the sole representation of a derived module's inherited
/// members, because that walk does not climb the composed template's parent chain.)
/// </param>
private static void CollectDirectMembers(
Template template,
@@ -116,43 +117,115 @@ public static class CollisionDetector
}
}
/// <summary>
/// A composed module's member, named relative to the module: <see cref="RelativeName"/>
/// is the member's path below the slot, <see cref="ModulePath"/> the nested-slot path
/// of the module that declares it (<c>null</c> for the module's own direct members).
/// </summary>
private sealed record RelativeMember(string RelativeName, string MemberType, string? ModulePath);
/// <summary>
/// Adds a composed module's members — its own and its nested modules', recursively —
/// under <paramref name="prefix"/>.
///
/// <para>
/// The module's member list is computed once per collision check and memoised by
/// template id in <paramref name="moduleMemo"/> (review 2026-10-21 04/P8(b)): a
/// module composed into several slots, or reached through several parents (a diamond),
/// used to have its whole subtree re-walked once per path. Each further slot now only
/// re-prefixes the memoised list. Every path still contributes its own canonical names
/// — a repeatedly-composed template must be checked under every slot.
/// </para>
/// </summary>
private static void CollectComposedMembers(
Template template,
string prefix,
Dictionary<int, Template> lookup,
List<ResolvedMember> members,
HashSet<int> visited)
Dictionary<int, IReadOnlyList<RelativeMember>> moduleMemo)
{
// `visited` is the current RECURSION PATH, not a global visited-set: an id is
// added on entry and REMOVED on exit (finally), so a template legitimately
// composed into more than one slot is re-descended for each slot. The early
// return fires only on a genuine cycle — an id already on the ACTIVE path.
// (A global visited-set would blind the detector to every collision reachable
// only through the second-and-later slot of a repeatedly-composed template.)
if (!visited.Add(template.Id))
return;
var relative = GetModuleMembers(template, lookup, moduleMemo, new HashSet<int>(), out _);
foreach (var m in relative)
{
var origin = m.ModulePath == null ? prefix : $"{prefix}.{m.ModulePath}";
members.Add(new ResolvedMember($"{prefix}.{m.RelativeName}", m.MemberType, $"module '{origin}'"));
}
}
/// <summary>
/// Computes (or returns the memoised) module-relative member list of
/// <paramref name="template"/> as a composed module.
/// </summary>
/// <param name="template">The composed module.</param>
/// <param name="lookup">All templates by id.</param>
/// <param name="moduleMemo">Per-check memo of complete (cycle-free) module member lists.</param>
/// <param name="activePath">
/// The ids on the current recursion PATH (not a global visited-set): a nested module
/// already on the path is a genuine cycle and contributes nothing, exactly as before.
/// </param>
/// <param name="truncatedByCycle">
/// True when a cycle cut this list short. Such a list depends on the path it was
/// computed from, so it — and every ancestor list built from it — is not memoised.
/// </param>
/// <returns>The module's members, relative to the module.</returns>
private static IReadOnlyList<RelativeMember> GetModuleMembers(
Template template,
Dictionary<int, Template> lookup,
Dictionary<int, IReadOnlyList<RelativeMember>> moduleMemo,
HashSet<int> activePath,
out bool truncatedByCycle)
{
truncatedByCycle = false;
if (moduleMemo.TryGetValue(template.Id, out var cached))
return cached;
activePath.Add(template.Id);
try
{
// Add direct members of this composed template with the prefix. Inherited
// placeholder rows are KEPT here: the composed-module walk does not climb the
// composed template's parent chain, so the placeholders are the only
// representation of a derived module's inherited members.
CollectDirectMembers(template, prefix, $"module '{prefix}'", members, skipInherited: false);
var result = new List<RelativeMember>();
// Recurse into nested compositions
// Direct members of the composed template. Inherited placeholder rows are
// KEPT here: the composed-module walk does not climb the composed template's
// parent chain, so the placeholders are the only representation of a derived
// module's inherited members.
foreach (var attr in template.Attributes)
result.Add(new RelativeMember(attr.Name, "Attribute", null));
foreach (var alarm in template.Alarms)
result.Add(new RelativeMember(alarm.Name, "Alarm", null));
foreach (var script in template.Scripts)
result.Add(new RelativeMember(script.Name, "Script", null));
// Nested compositions.
foreach (var composition in template.Compositions)
{
if (lookup.TryGetValue(composition.ComposedTemplateId, out var nested))
if (!lookup.TryGetValue(composition.ComposedTemplateId, out var nested))
continue;
if (activePath.Contains(nested.Id))
{
var nestedPrefix = $"{prefix}.{composition.InstanceName}";
CollectComposedMembers(nested, nestedPrefix, lookup, members, visited);
truncatedByCycle = true;
continue;
}
var slot = composition.InstanceName;
var nestedMembers = GetModuleMembers(nested, lookup, moduleMemo, activePath, out var nestedTruncated);
truncatedByCycle |= nestedTruncated;
foreach (var m in nestedMembers)
{
result.Add(new RelativeMember(
$"{slot}.{m.RelativeName}",
m.MemberType,
m.ModulePath == null ? slot : $"{slot}.{m.ModulePath}"));
}
}
if (!truncatedByCycle)
moduleMemo[template.Id] = result;
return result;
}
finally
{
visited.Remove(template.Id);
activePath.Remove(template.Id);
}
}
@@ -160,6 +233,7 @@ public static class CollisionDetector
Template template,
Dictionary<int, Template> lookup,
List<ResolvedMember> members,
Dictionary<int, IReadOnlyList<RelativeMember>> moduleMemo,
HashSet<int> visited)
{
if (!template.ParentTemplateId.HasValue)
@@ -186,11 +260,11 @@ public static class CollisionDetector
composition.InstanceName,
lookup,
members,
new HashSet<int>());
moduleMemo);
}
}
// Continue up the inheritance chain
CollectInheritedMembers(parent, lookup, members, visited);
CollectInheritedMembers(parent, lookup, members, moduleMemo, visited);
}
}