< Summary

Information
Class: Intentum.Experiments.IntentExperiment
Assembly: Intentum.Experiments
File(s): /home/runner/work/Intentum/Intentum/src/Intentum.Experiments/IntentExperiment.cs
Line coverage
60%
Covered lines: 72
Uncovered lines: 47
Coverable lines: 119
Total lines: 207
Line coverage: 60.5%
Branch coverage
54%
Covered branches: 24
Total branches: 44
Branch coverage: 54.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Cyclomatic complexity NPath complexity Sequence coverage
.ctor()100%11100%
AddVariant(...)75%44100%
SplitTraffic(...)50%22100%
RunAsync(...)100%44100%
Run(...)100%11100%
ComputeSignificance(...)20%101022.22%
GetSpaceIdentity(...)50%4477.77%
SelectVariantByHash(...)75%4491.66%
StableHash(...)100%22100%
NormalizeSplit(...)87.5%88100%
ChiSquarePValue(...)0%440%
Erfc(...)0%220%

File(s)

/home/runner/work/Intentum/Intentum/src/Intentum.Experiments/IntentExperiment.cs

#LineLine coverage
 1using Intentum.Core.Behavior;
 2using Intentum.Core.Contracts;
 3using Intentum.Runtime.Engine;
 4using Intentum.Runtime.Policy;
 5
 6namespace Intentum.Experiments;
 7
 8/// <summary>
 9/// A/B experiment: run behavior spaces through multiple variants with deterministic
 10/// hash-based traffic splitting and statistical significance reporting.
 11/// </summary>
 12public sealed class IntentExperiment
 13{
 514    private readonly List<ExperimentVariant> _variants = [];
 515    private readonly List<int> _trafficSplit = [];
 16
 17    /// <summary>
 18    /// Adds a variant (model + policy) to the experiment.
 19    /// </summary>
 20    public IntentExperiment AddVariant(string name, IIntentModel model, IntentPolicy policy)
 621    {
 622        _variants.Add(new ExperimentVariant(name, model ?? throw new ArgumentNullException(nameof(model)),
 623            policy ?? throw new ArgumentNullException(nameof(policy))));
 524        return this;
 525    }
 26
 27    /// <summary>
 28    /// Sets traffic split percentages (e.g. 50, 50 for two variants). Must sum to 100 and match variant count.
 29    /// </summary>
 30    public IntentExperiment SplitTraffic(params int[]? percentages)
 231    {
 232        _trafficSplit.Clear();
 233        _trafficSplit.AddRange(percentages ?? []);
 234        return this;
 235    }
 36
 37    /// <summary>
 38    /// Runs the experiment with deterministic hash-based variant assignment.
 39    /// Each behavior space is assigned a variant based on a stable hash of its identity,
 40    /// ensuring the same space always gets the same variant across runs.
 41    /// </summary>
 42    public Task<IReadOnlyList<ExperimentResult>> RunAsync(
 43        IReadOnlyList<BehaviorSpace> behaviorSpaces,
 44        CancellationToken cancellationToken = default)
 545    {
 546        cancellationToken.ThrowIfCancellationRequested();
 547        if (_variants.Count == 0)
 148            throw new InvalidOperationException("Add at least one variant with AddVariant.");
 49
 450        var split = NormalizeSplit(_trafficSplit, _variants.Count);
 451        var results = new List<ExperimentResult>();
 52
 6053        foreach (var space in behaviorSpaces)
 2454        {
 2455            cancellationToken.ThrowIfCancellationRequested();
 2456            var identity = GetSpaceIdentity(space);
 2457            var variantIndex = SelectVariantByHash(identity, split);
 2458            var variant = _variants[variantIndex];
 2459            var intent = variant.Model.Infer(space);
 2460            var decision = IntentPolicyEngine.Evaluate(intent, variant.Policy);
 2461            results.Add(new ExperimentResult(variant.Name, identity, intent, decision));
 2462        }
 63
 464        return Task.FromResult<IReadOnlyList<ExperimentResult>>(results);
 465    }
 66
 67    /// <summary>
 68    /// Synchronous batch run.
 69    /// </summary>
 70    public IReadOnlyList<ExperimentResult> Run(IReadOnlyList<BehaviorSpace> behaviorSpaces)
 271        => RunAsync(behaviorSpaces).GetAwaiter().GetResult();
 72
 73    /// <summary>
 74    /// Computes basic statistical significance between two variants using a chi-square test
 75    /// on the distribution of decisions.
 76    /// </summary>
 77    public static ExperimentSignificance ComputeSignificance(
 78        IReadOnlyList<ExperimentResult> results,
 79        string variantA,
 80        string variantB)
 281    {
 2282        var groupA = results.Where(r => r.VariantName == variantA).ToList();
 2283        var groupB = results.Where(r => r.VariantName == variantB).ToList();
 84
 285        if (groupA.Count == 0 || groupB.Count == 0)
 286            return new ExperimentSignificance(0, 1.0, false);
 87
 088        var allDecisions = groupA.Select(r => r.Decision)
 089            .Concat(groupB.Select(r => r.Decision))
 090            .Distinct()
 091            .ToList();
 92
 093        var chiSquare = 0.0;
 094        var totalA = (double)groupA.Count;
 095        var totalB = (double)groupB.Count;
 096        var total = totalA + totalB;
 97
 098        foreach (var decision in allDecisions)
 099        {
 0100            var observedA = groupA.Count(r => r.Decision == decision);
 0101            var observedB = groupB.Count(r => r.Decision == decision);
 0102            var totalForDecision = observedA + observedB;
 103
 0104            var expectedA = totalForDecision * (totalA / total);
 0105            var expectedB = totalForDecision * (totalB / total);
 106
 0107            if (expectedA > 0) chiSquare += Math.Pow(observedA - expectedA, 2) / expectedA;
 0108            if (expectedB > 0) chiSquare += Math.Pow(observedB - expectedB, 2) / expectedB;
 0109        }
 110
 0111        var degreesOfFreedom = Math.Max(1, allDecisions.Count - 1);
 0112        var pValue = ChiSquarePValue(chiSquare, degreesOfFreedom);
 113
 0114        return new ExperimentSignificance(chiSquare, pValue, pValue < 0.05);
 2115    }
 116
 117    private static string GetSpaceIdentity(BehaviorSpace space)
 24118    {
 24119        var entityId = space.GetMetadata<string>("entityId");
 24120        if (!string.IsNullOrEmpty(entityId))
 0121            return entityId;
 122
 24123        var sessionId = space.GetMetadata<string>("sessionId");
 24124        if (!string.IsNullOrEmpty(sessionId))
 0125            return sessionId;
 126
 73127        return string.Join("|", space.Events.Select(e => $"{e.Actor}:{e.Action}"));
 24128    }
 129
 130    private static int SelectVariantByHash(string identity, int[] split)
 24131    {
 24132        var hash = StableHash(identity);
 24133        var bucket = (int)(((uint)hash) % 100);
 24134        var cumulative = 0;
 90135        for (var i = 0; i < split.Length; i++)
 45136        {
 45137            cumulative += split[i];
 45138            if (bucket < cumulative)
 24139                return i;
 21140        }
 0141        return split.Length - 1;
 24142    }
 143
 144    private static int StableHash(string input)
 24145    {
 146        unchecked
 24147        {
 24148            var hash = 5381;
 1150149            foreach (var c in input)
 539150                hash = ((hash << 5) + hash) + c;
 24151            return hash;
 152        }
 24153    }
 154
 155    private static int[] NormalizeSplit(List<int> requested, int variantCount)
 4156    {
 4157        if (requested.Count == variantCount && requested.Sum() == 100)
 2158            return requested.ToArray();
 2159        var equal = 100 / variantCount;
 2160        var remainder = 100 % variantCount;
 2161        var split = new int[variantCount];
 8162        for (var i = 0; i < variantCount; i++)
 2163            split[i] = equal + (i < remainder ? 1 : 0);
 2164        return split;
 4165    }
 166
 167    /// <summary>
 168    /// Approximation of chi-square p-value using the Wilson-Hilferty transformation.
 169    /// </summary>
 170    private static double ChiSquarePValue(double chiSquare, int degreesOfFreedom)
 0171    {
 0172        if (chiSquare <= 0 || degreesOfFreedom <= 0) return 1.0;
 173
 0174        var k = degreesOfFreedom;
 0175        var z = Math.Pow(chiSquare / k, 1.0 / 3) - (1.0 - 2.0 / (9.0 * k));
 0176        z /= Math.Sqrt(2.0 / (9.0 * k));
 177
 0178        var pValue = 0.5 * Erfc(z / Math.Sqrt(2));
 0179        return Math.Clamp(pValue, 0, 1);
 0180    }
 181
 182    private static double Erfc(double x)
 0183    {
 0184        var t = 1.0 / (1.0 + 0.5 * Math.Abs(x));
 0185        var tau = t * Math.Exp(
 0186            -x * x - 1.26551223 +
 0187            t * (1.00002368 +
 0188            t * (0.37409196 +
 0189            t * (0.09678418 +
 0190            t * (-0.18628806 +
 0191            t * (0.27886807 +
 0192            t * (-1.13520398 +
 0193            t * (1.48851587 +
 0194            t * (-0.82215223 +
 0195            t * 0.17087277)))))))));
 0196        return x >= 0 ? tau : 2 - tau;
 0197    }
 198}
 199
 200/// <summary>
 201/// Result of statistical significance test between two experiment variants.
 202/// </summary>
 203public sealed record ExperimentSignificance(
 204    double ChiSquare,
 205    double PValue,
 206    bool IsSignificant
 207);