save progress

This commit is contained in:
StellaOps Bot
2025-12-18 09:10:36 +02:00
parent b4235c134c
commit 28823a8960
169 changed files with 11995 additions and 449 deletions

View File

@@ -140,4 +140,16 @@ public sealed record CallgraphEdge
/// </summary>
[JsonPropertyName("provenance")]
public string? Provenance { get; init; }
/// <summary>
/// Gates detected on this edge.
/// </summary>
[JsonPropertyName("gates")]
public IReadOnlyList<CallgraphGate>? Gates { get; init; }
/// <summary>
/// Combined gate multiplier in basis points (10000 = 100%).
/// </summary>
[JsonPropertyName("gateMultiplierBps")]
public int GateMultiplierBps { get; init; } = 10000;
}

View File

@@ -0,0 +1,52 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Models;
/// <summary>
/// A detected gate protecting a code path.
/// </summary>
public sealed record CallgraphGate
{
/// <summary>
/// Type of gate.
/// </summary>
[JsonPropertyName("type")]
public CallgraphGateType Type { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
[JsonPropertyName("detail")]
public string Detail { get; init; } = string.Empty;
/// <summary>
/// Symbol where gate was detected.
/// </summary>
[JsonPropertyName("guardSymbol")]
public string GuardSymbol { get; init; } = string.Empty;
/// <summary>
/// Source file (if available).
/// </summary>
[JsonPropertyName("sourceFile")]
public string? SourceFile { get; init; }
/// <summary>
/// Line number (if available).
/// </summary>
[JsonPropertyName("lineNumber")]
public int? LineNumber { get; init; }
/// <summary>
/// Confidence score (0.0-1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; } = 1.0;
/// <summary>
/// Detection method used.
/// </summary>
[JsonPropertyName("detectionMethod")]
public string DetectionMethod { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Models;
/// <summary>
/// Types of gates that can protect code paths.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CallgraphGateType
{
AuthRequired,
FeatureFlag,
AdminOnly,
NonDefaultConfig
}

View File

@@ -95,6 +95,16 @@ public sealed class ReachabilityEvidenceDocument
public List<string> RuntimeHits { get; set; } = new();
public List<string>? BlockedEdges { get; set; }
/// <summary>
/// Combined gate multiplier in basis points (10000 = 100%).
/// </summary>
public int GateMultiplierBps { get; set; } = 10000;
/// <summary>
/// Gates detected on the computed path to the target (if any).
/// </summary>
public List<CallgraphGate>? Gates { get; set; }
}
public sealed class ReachabilitySubject

View File

@@ -0,0 +1,47 @@
using System;
using StellaOps.Signals.Models;
namespace StellaOps.Signals.Options;
/// <summary>
/// Gate multiplier configuration in basis points (10000 = 100%).
/// </summary>
public sealed class SignalsGateMultiplierOptions
{
public int AuthRequiredMultiplierBps { get; set; } = 3000;
public int FeatureFlagMultiplierBps { get; set; } = 2000;
public int AdminOnlyMultiplierBps { get; set; } = 1500;
public int NonDefaultConfigMultiplierBps { get; set; } = 5000;
public int MinimumMultiplierBps { get; set; } = 500;
public int GetMultiplierBps(CallgraphGateType gateType) => gateType switch
{
CallgraphGateType.AuthRequired => AuthRequiredMultiplierBps,
CallgraphGateType.FeatureFlag => FeatureFlagMultiplierBps,
CallgraphGateType.AdminOnly => AdminOnlyMultiplierBps,
CallgraphGateType.NonDefaultConfig => NonDefaultConfigMultiplierBps,
_ => 10000
};
public void Validate()
{
EnsureBps(nameof(AuthRequiredMultiplierBps), AuthRequiredMultiplierBps);
EnsureBps(nameof(FeatureFlagMultiplierBps), FeatureFlagMultiplierBps);
EnsureBps(nameof(AdminOnlyMultiplierBps), AdminOnlyMultiplierBps);
EnsureBps(nameof(NonDefaultConfigMultiplierBps), NonDefaultConfigMultiplierBps);
EnsureBps(nameof(MinimumMultiplierBps), MinimumMultiplierBps);
}
private static void EnsureBps(string name, int value)
{
if (value < 0 || value > 10000)
{
throw new ArgumentOutOfRangeException(name, value, "Value must be between 0 and 10000.");
}
}
}

View File

@@ -7,6 +7,11 @@ namespace StellaOps.Signals.Options;
/// </summary>
public sealed class SignalsScoringOptions
{
/// <summary>
/// Gate multipliers applied when reachability paths are protected by gates.
/// </summary>
public SignalsGateMultiplierOptions GateMultipliers { get; } = new();
/// <summary>
/// Confidence assigned when a path exists from entry point to target.
/// </summary>
@@ -62,6 +67,8 @@ public sealed class SignalsScoringOptions
public void Validate()
{
GateMultipliers.Validate();
EnsurePercent(nameof(ReachableConfidence), ReachableConfidence);
EnsurePercent(nameof(UnreachableConfidence), UnreachableConfidence);
EnsurePercent(nameof(RuntimeBonus), RuntimeBonus);

View File

@@ -113,7 +113,9 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
SymbolDigest = GetString(edgeElement, "symbol_digest", "symbolDigest"),
Candidates = GetStringArray(edgeElement, "candidates"),
Confidence = GetNullableDouble(edgeElement, "confidence"),
Evidence = GetStringArray(edgeElement, "evidence")
Evidence = GetStringArray(edgeElement, "evidence"),
Gates = ParseGates(edgeElement),
GateMultiplierBps = GetNullableInt(edgeElement, "gate_multiplier_bps", "gateMultiplierBps") ?? 10000
});
}
}
@@ -212,7 +214,9 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
SymbolDigest = GetString(edgeElement, "symbol_digest", "symbolDigest"),
Candidates = GetStringArray(edgeElement, "candidates"),
Confidence = GetNullableDouble(edgeElement, "confidence"),
Evidence = GetStringArray(edgeElement, "evidence")
Evidence = GetStringArray(edgeElement, "evidence"),
Gates = ParseGates(edgeElement),
GateMultiplierBps = GetNullableInt(edgeElement, "gate_multiplier_bps", "gateMultiplierBps") ?? 10000
});
}
}
@@ -285,7 +289,9 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
SymbolDigest = GetString(edgeElement, "symbol_digest", "symbolDigest"),
Candidates = GetStringArray(edgeElement, "candidates"),
Confidence = GetNullableDouble(edgeElement, "confidence"),
Evidence = GetStringArray(edgeElement, "evidence")
Evidence = GetStringArray(edgeElement, "evidence"),
Gates = ParseGates(edgeElement),
GateMultiplierBps = GetNullableInt(edgeElement, "gate_multiplier_bps", "gateMultiplierBps") ?? 10000
});
}
@@ -434,4 +440,73 @@ public sealed class SimpleJsonCallgraphParser : ICallgraphParser
};
}
private static int? GetNullableInt(JsonElement element, string name1, string? name2 = null)
{
if (element.TryGetProperty(name1, out var v1) && v1.ValueKind == JsonValueKind.Number && v1.TryGetInt32(out var i1))
{
return i1;
}
if (!string.IsNullOrEmpty(name2)
&& element.TryGetProperty(name2!, out var v2)
&& v2.ValueKind == JsonValueKind.Number
&& v2.TryGetInt32(out var i2))
{
return i2;
}
return null;
}
private static IReadOnlyList<CallgraphGate>? ParseGates(JsonElement edgeElement)
{
if (!edgeElement.TryGetProperty("gates", out var gatesEl) || gatesEl.ValueKind != JsonValueKind.Array)
{
return null;
}
var gates = new List<CallgraphGate>(gatesEl.GetArrayLength());
foreach (var gateEl in gatesEl.EnumerateArray())
{
if (gateEl.ValueKind != JsonValueKind.Object)
{
continue;
}
var typeRaw = GetString(gateEl, "type");
if (!TryParseGateType(typeRaw, out var gateType))
{
continue;
}
gates.Add(new CallgraphGate
{
Type = gateType,
Detail = GetString(gateEl, "detail") ?? string.Empty,
GuardSymbol = GetString(gateEl, "guard_symbol", "guardSymbol") ?? string.Empty,
SourceFile = GetString(gateEl, "source_file", "sourceFile"),
LineNumber = GetNullableInt(gateEl, "line_number", "lineNumber"),
Confidence = GetNullableDouble(gateEl, "confidence") ?? 1.0,
DetectionMethod = GetString(gateEl, "detection_method", "detectionMethod") ?? string.Empty
});
}
return gates.Count == 0 ? null : gates;
}
private static bool TryParseGateType(string? raw, out CallgraphGateType gateType)
{
gateType = default;
if (string.IsNullOrWhiteSpace(raw))
{
return false;
}
var normalized = raw.Trim()
.Replace("_", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal);
return Enum.TryParse(normalized, ignoreCase: true, out gateType);
}
}

View File

@@ -127,6 +127,19 @@ internal sealed class InMemoryCallgraphRepository : ICallgraphRepository
Weight = source.Weight,
Offset = source.Offset,
IsResolved = source.IsResolved,
Provenance = source.Provenance
Provenance = source.Provenance,
GateMultiplierBps = source.GateMultiplierBps,
Gates = source.Gates?.Select(CloneGate).ToList()
};
private static CallgraphGate CloneGate(CallgraphGate source) => new()
{
Type = source.Type,
Detail = source.Detail,
GuardSymbol = source.GuardSymbol,
SourceFile = source.SourceFile,
LineNumber = source.LineNumber,
Confidence = source.Confidence,
DetectionMethod = source.DetectionMethod
};
}

View File

@@ -119,14 +119,30 @@ internal sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepo
Reachable = source.Reachable,
Confidence = source.Confidence,
Bucket = source.Bucket,
LatticeState = source.LatticeState,
PreviousLatticeState = source.PreviousLatticeState,
Weight = source.Weight,
Score = source.Score,
Path = source.Path.ToList(),
Evidence = new ReachabilityEvidenceDocument
{
RuntimeHits = source.Evidence.RuntimeHits.ToList(),
BlockedEdges = source.Evidence.BlockedEdges?.ToList()
}
BlockedEdges = source.Evidence.BlockedEdges?.ToList(),
GateMultiplierBps = source.Evidence.GateMultiplierBps,
Gates = source.Evidence.Gates?.Select(CloneGate).ToList()
},
LatticeTransitionAt = source.LatticeTransitionAt
};
private static CallgraphGate CloneGate(CallgraphGate source) => new()
{
Type = source.Type,
Detail = source.Detail,
GuardSymbol = source.GuardSymbol,
SourceFile = source.SourceFile,
LineNumber = source.LineNumber,
Confidence = source.Confidence,
DetectionMethod = source.DetectionMethod
};
private static RuntimeFactDocument CloneRuntime(RuntimeFactDocument source) => new()

View File

@@ -272,6 +272,8 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
.Append(edge.Offset?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).Append('|')
.Append(edge.IsResolved).Append('|')
.Append(edge.Provenance).Append('|')
.Append(edge.GateMultiplierBps.ToString(CultureInfo.InvariantCulture)).Append('|')
.Append(JoinGates(edge.Gates)).Append('|')
.Append(edge.Purl).Append('|')
.Append(edge.SymbolDigest).Append('|')
.Append(edge.Confidence?.ToString("G17", CultureInfo.InvariantCulture) ?? string.Empty).Append('|')
@@ -330,6 +332,49 @@ internal sealed class CallgraphIngestionService : ICallgraphIngestionService
return ordered.ToString();
}
private static string JoinGates(IReadOnlyList<CallgraphGate>? gates)
{
if (gates is null || gates.Count == 0)
{
return string.Empty;
}
var ordered = gates
.Where(g => g is not null)
.Select(g => g with
{
GuardSymbol = g.GuardSymbol?.Trim() ?? string.Empty,
Detail = g.Detail?.Trim() ?? string.Empty,
DetectionMethod = g.DetectionMethod?.Trim() ?? string.Empty,
SourceFile = string.IsNullOrWhiteSpace(g.SourceFile) ? null : g.SourceFile.Trim(),
LineNumber = g.LineNumber is > 0 ? g.LineNumber : null,
Confidence = double.IsNaN(g.Confidence) ? 0.0 : Math.Clamp(g.Confidence, 0.0, 1.0)
})
.OrderBy(g => g.Type)
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
.ThenBy(g => g.Detail, StringComparer.Ordinal)
.ThenBy(g => g.SourceFile, StringComparer.Ordinal)
.ThenBy(g => g.LineNumber ?? 0)
.ToList();
var builder = new StringBuilder();
foreach (var gate in ordered)
{
builder
.Append(gate.Type).Append(':')
.Append(gate.GuardSymbol).Append(':')
.Append(gate.DetectionMethod).Append(':')
.Append(gate.Confidence.ToString("G17", CultureInfo.InvariantCulture)).Append(':')
.Append(gate.SourceFile).Append(':')
.Append(gate.LineNumber?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).Append(':')
.Append(gate.Detail)
.Append(';');
}
return builder.ToString();
}
}
/// <summary>

View File

@@ -108,6 +108,8 @@ internal sealed class CallgraphNormalizationService : ICallgraphNormalizationSer
continue;
}
var normalizedGates = NormalizeGates(edge.Gates);
list.Add(edge with
{
SourceId = source,
@@ -117,7 +119,9 @@ internal sealed class CallgraphNormalizationService : ICallgraphNormalizationSer
SymbolDigest = NormalizeDigest(edge.SymbolDigest),
Confidence = ClampConfidence(edge.Confidence),
Candidates = NormalizeList(edge.Candidates),
Evidence = NormalizeList(edge.Evidence)
Evidence = NormalizeList(edge.Evidence),
Gates = normalizedGates,
GateMultiplierBps = ClampGateMultiplierBps(edge.GateMultiplierBps)
});
}
@@ -127,6 +131,47 @@ internal sealed class CallgraphNormalizationService : ICallgraphNormalizationSer
.ToList();
}
private static IReadOnlyList<CallgraphGate>? NormalizeGates(IReadOnlyList<CallgraphGate>? gates)
{
if (gates is null || gates.Count == 0)
{
return null;
}
var unique = new Dictionary<(CallgraphGateType Type, string GuardSymbol), CallgraphGate>();
foreach (var gate in gates.Where(g => g is not null))
{
var guardSymbol = gate.GuardSymbol?.Trim() ?? string.Empty;
var normalized = gate with
{
GuardSymbol = guardSymbol,
Detail = gate.Detail?.Trim() ?? string.Empty,
DetectionMethod = gate.DetectionMethod?.Trim() ?? string.Empty,
SourceFile = string.IsNullOrWhiteSpace(gate.SourceFile) ? null : gate.SourceFile.Trim(),
LineNumber = gate.LineNumber is > 0 ? gate.LineNumber : null,
Confidence = double.IsNaN(gate.Confidence) ? 0.0 : Math.Clamp(gate.Confidence, 0.0, 1.0)
};
var key = (normalized.Type, guardSymbol);
if (!unique.TryGetValue(key, out var existing) || normalized.Confidence > existing.Confidence)
{
unique[key] = normalized;
}
}
return unique.Values
.OrderBy(g => g.Type)
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
.ThenBy(g => g.Detail, StringComparer.Ordinal)
.ThenBy(g => g.SourceFile, StringComparer.Ordinal)
.ThenBy(g => g.LineNumber ?? 0)
.ToList();
}
private static int ClampGateMultiplierBps(int multiplierBps)
=> Math.Clamp(multiplierBps, 0, 10000);
private static IReadOnlyList<CallgraphRoot> NormalizeRoots(IReadOnlyList<CallgraphRoot>? roots)
{
var list = new List<CallgraphRoot>();

View File

@@ -70,7 +70,35 @@ internal static class ReachabilityFactDigestCalculator
Score: state.Score,
Path: NormalizeList(state.Path),
RuntimeHits: NormalizeList(state.Evidence?.RuntimeHits),
BlockedEdges: NormalizeList(state.Evidence?.BlockedEdges)))
BlockedEdges: NormalizeList(state.Evidence?.BlockedEdges),
GateMultiplierBps: Math.Clamp(state.Evidence?.GateMultiplierBps ?? 10000, 0, 10000),
Gates: NormalizeGates(state.Evidence?.Gates)))
.ToList();
}
private static List<CanonicalGate> NormalizeGates(IEnumerable<CallgraphGate>? gates)
{
if (gates is null)
{
return new List<CanonicalGate>();
}
return gates
.Where(g => g is not null)
.Select(g => new CanonicalGate(
Type: g.Type.ToString(),
GuardSymbol: g.GuardSymbol?.Trim() ?? string.Empty,
DetectionMethod: g.DetectionMethod?.Trim() ?? string.Empty,
Confidence: double.IsNaN(g.Confidence) ? 0.0 : Math.Clamp(g.Confidence, 0.0, 1.0),
SourceFile: string.IsNullOrWhiteSpace(g.SourceFile) ? null : g.SourceFile.Trim(),
LineNumber: g.LineNumber is > 0 ? g.LineNumber : null,
Detail: g.Detail?.Trim() ?? string.Empty))
.OrderBy(g => g.Type, StringComparer.Ordinal)
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
.ThenBy(g => g.SourceFile, StringComparer.Ordinal)
.ThenBy(g => g.LineNumber ?? 0)
.ThenBy(g => g.Detail, StringComparer.Ordinal)
.ToList();
}
@@ -192,7 +220,18 @@ internal static class ReachabilityFactDigestCalculator
double Score,
List<string> Path,
List<string> RuntimeHits,
List<string> BlockedEdges);
List<string> BlockedEdges,
int GateMultiplierBps,
List<CanonicalGate> Gates);
private sealed record CanonicalGate(
string Type,
string GuardSymbol,
string DetectionMethod,
double Confidence,
string? SourceFile,
int? LineNumber,
string Detail);
private sealed record CanonicalRuntimeFact(
string SymbolId,

View File

@@ -68,6 +68,7 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
? list
: Array.Empty<ReachabilityBlockedEdge>();
var graph = BuildGraph(callgraph, blockedEdges);
var edgeGateMap = BuildEdgeGateMap(callgraph, blockedEdges);
var entryPoints = NormalizeEntryPoints(request.EntryPoints, graph.Nodes, graph.Inbound);
var targets = request.Targets.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).Distinct(StringComparer.Ordinal).ToList();
if (targets.Count == 0)
@@ -101,6 +102,7 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
var path = FindPath(entryPoints, target, graph.Adjacency);
var reachable = path is not null;
var runtimeEvidence = runtimeHits.Where(hit => path?.Contains(hit, StringComparer.Ordinal) == true).ToList();
var (pathGateMultiplierBps, pathGates) = ComputePathGateMultiplier(path, edgeGateMap);
var (bucket, weight, confidence) = ComputeScores(
reachable,
@@ -109,7 +111,7 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
path,
runtimeEvidence.Count);
var score = confidence * weight;
var score = confidence * weight * pathGateMultiplierBps / 10000.0;
runtimeEvidence = runtimeEvidence.OrderBy(hit => hit, StringComparer.Ordinal).ToList();
@@ -139,7 +141,9 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
BlockedEdges = request.BlockedEdges?
.Select(edge => $"{edge.From} -> {edge.To}")
.OrderBy(edge => edge, StringComparer.Ordinal)
.ToList()
.ToList(),
GateMultiplierBps = pathGateMultiplierBps,
Gates = pathGates
},
LatticeTransitionAt = previousLatticeState != latticeState.ToCode() ? computedAt : existingState?.LatticeTransitionAt
});
@@ -387,6 +391,177 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
return new ReachabilityGraph(nodes, adjacency, inbound);
}
private Dictionary<(string From, string To), EdgeGateInfo> BuildEdgeGateMap(CallgraphDocument document, IEnumerable<ReachabilityBlockedEdge> blockedEdges)
{
var blocked = new HashSet<(string From, string To)>(new ReachabilityBlockedEdgeComparer());
foreach (var blockedEdge in blockedEdges)
{
if (!string.IsNullOrWhiteSpace(blockedEdge.From) && !string.IsNullOrWhiteSpace(blockedEdge.To))
{
blocked.Add((blockedEdge.From.Trim(), blockedEdge.To.Trim()));
}
}
var map = new Dictionary<(string From, string To), EdgeGateInfo>();
foreach (var edge in document.Edges)
{
if (blocked.Contains((edge.SourceId, edge.TargetId)))
{
continue;
}
var from = edge.SourceId?.Trim();
var to = edge.TargetId?.Trim();
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
{
continue;
}
var normalizedGates = NormalizeGates(edge.Gates);
var multiplierBps = ComputeGateMultiplierBps(normalizedGates);
var key = (from, to);
if (!map.TryGetValue(key, out var existing))
{
map[key] = new EdgeGateInfo(multiplierBps, normalizedGates);
continue;
}
if (multiplierBps > existing.GateMultiplierBps)
{
map[key] = new EdgeGateInfo(multiplierBps, normalizedGates);
continue;
}
if (multiplierBps == existing.GateMultiplierBps
&& (normalizedGates?.Count ?? 0) < (existing.Gates?.Count ?? 0))
{
map[key] = new EdgeGateInfo(multiplierBps, normalizedGates);
}
}
return map;
}
private (int GateMultiplierBps, List<CallgraphGate>? Gates) ComputePathGateMultiplier(
List<string>? path,
IReadOnlyDictionary<(string From, string To), EdgeGateInfo> edgeGateMap)
{
if (path is null || path.Count < 2)
{
return (10000, null);
}
var gatesByKey = new Dictionary<(CallgraphGateType Type, string GuardSymbol), CallgraphGate>();
for (var i = 0; i < path.Count - 1; i++)
{
var from = path[i];
var to = path[i + 1];
if (!edgeGateMap.TryGetValue((from, to), out var edgeInfo) || edgeInfo.Gates is not { Count: > 0 })
{
continue;
}
foreach (var gate in edgeInfo.Gates)
{
var guardSymbol = gate.GuardSymbol?.Trim() ?? string.Empty;
var normalized = gate with
{
GuardSymbol = guardSymbol,
Detail = gate.Detail?.Trim() ?? string.Empty,
DetectionMethod = gate.DetectionMethod?.Trim() ?? string.Empty,
SourceFile = string.IsNullOrWhiteSpace(gate.SourceFile) ? null : gate.SourceFile.Trim(),
LineNumber = gate.LineNumber is > 0 ? gate.LineNumber : null,
Confidence = double.IsNaN(gate.Confidence) ? 0.0 : Math.Clamp(gate.Confidence, 0.0, 1.0)
};
var key = (normalized.Type, guardSymbol);
if (!gatesByKey.TryGetValue(key, out var existing) || normalized.Confidence > existing.Confidence)
{
gatesByKey[key] = normalized;
}
}
}
if (gatesByKey.Count == 0)
{
return (10000, null);
}
var gates = gatesByKey.Values
.OrderBy(g => g.Type)
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
.ThenBy(g => g.Detail, StringComparer.Ordinal)
.ThenBy(g => g.SourceFile, StringComparer.Ordinal)
.ThenBy(g => g.LineNumber ?? 0)
.ToList();
return (ComputeGateMultiplierBps(gates), gates);
}
private int ComputeGateMultiplierBps(IReadOnlyList<CallgraphGate>? gates)
{
if (gates is null || gates.Count == 0)
{
return 10000;
}
var gateTypes = gates
.Select(g => g.Type)
.Distinct()
.OrderBy(t => t)
.ToList();
double multiplierBps = 10000.0;
foreach (var gateType in gateTypes)
{
multiplierBps = multiplierBps * scoringOptions.GateMultipliers.GetMultiplierBps(gateType) / 10000.0;
}
var result = (int)Math.Round(multiplierBps);
result = Math.Clamp(result, 0, 10000);
return Math.Max(result, scoringOptions.GateMultipliers.MinimumMultiplierBps);
}
private static IReadOnlyList<CallgraphGate>? NormalizeGates(IReadOnlyList<CallgraphGate>? gates)
{
if (gates is null || gates.Count == 0)
{
return null;
}
var unique = new Dictionary<(CallgraphGateType Type, string GuardSymbol), CallgraphGate>();
foreach (var gate in gates.Where(g => g is not null))
{
var guardSymbol = gate.GuardSymbol?.Trim() ?? string.Empty;
var normalized = gate with
{
GuardSymbol = guardSymbol,
Detail = gate.Detail?.Trim() ?? string.Empty,
DetectionMethod = gate.DetectionMethod?.Trim() ?? string.Empty,
SourceFile = string.IsNullOrWhiteSpace(gate.SourceFile) ? null : gate.SourceFile.Trim(),
LineNumber = gate.LineNumber is > 0 ? gate.LineNumber : null,
Confidence = double.IsNaN(gate.Confidence) ? 0.0 : Math.Clamp(gate.Confidence, 0.0, 1.0)
};
var key = (normalized.Type, guardSymbol);
if (!unique.TryGetValue(key, out var existing) || normalized.Confidence > existing.Confidence)
{
unique[key] = normalized;
}
}
return unique.Values
.OrderBy(g => g.Type)
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
.ThenBy(g => g.Detail, StringComparer.Ordinal)
.ThenBy(g => g.SourceFile, StringComparer.Ordinal)
.ThenBy(g => g.LineNumber ?? 0)
.ToList();
}
private static List<string> NormalizeEntryPoints(IEnumerable<string> requestedEntries, HashSet<string> nodes, Dictionary<string, HashSet<string>> inbound)
{
var entries = requestedEntries?
@@ -516,6 +691,8 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
Dictionary<string, HashSet<string>> Adjacency,
Dictionary<string, HashSet<string>> Inbound);
private sealed record EdgeGateInfo(int GateMultiplierBps, IReadOnlyList<CallgraphGate>? Gates);
private sealed class ReachabilityBlockedEdgeComparer : IEqualityComparer<(string From, string To)>
{
public bool Equals((string From, string To) x, (string From, string To) y)

View File

@@ -9,3 +9,6 @@ This file mirrors sprint work for the Signals module.
| `UNCERTAINTY-SCORER-401-025` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Reachability risk score now uses configurable entropy weights and is aligned with `UncertaintyDocument.RiskScore`; tests cover tier/entropy scoring. |
| `UNKNOWNS-DECAY-3601-001` | `docs/implplan/SPRINT_3601_0001_0001_unknowns_decay_algorithm.md` | DONE (2025-12-17) | Implemented decay worker/service, signal refresh hook, and deterministic unit/integration tests. |
| `TRI-MASTER-0003` | `docs/implplan/SPRINT_3600_0001_0001_triage_unknowns_master.md` | DONE (2025-12-17) | Synced Signals AGENTS with Unknowns scoring/decay contracts and configuration sections. |
| `GATE-3405-011` | `docs/implplan/SPRINT_3405_0001_0001_gate_multipliers.md` | DONE (2025-12-18) | Applied gate multipliers in `ReachabilityScoringService` using path gate evidence from callgraph edges. |
| `GATE-3405-012` | `docs/implplan/SPRINT_3405_0001_0001_gate_multipliers.md` | DONE (2025-12-18) | Extended reachability fact evidence contract + digest to include `GateMultiplierBps` and `Gates`. |
| `GATE-3405-016` | `docs/implplan/SPRINT_3405_0001_0001_gate_multipliers.md` | DONE (2025-12-18) | Added deterministic parser/normalizer/scoring coverage for gate propagation + multiplier effect. |