< Summary

Information
Class: Intentum.Experiments.ExperimentSignificance
Assembly: Intentum.Experiments
File(s): /home/runner/work/Intentum/Intentum/src/Intentum.Experiments/IntentExperiment.cs
Line coverage
80%
Covered lines: 4
Uncovered lines: 1
Coverable lines: 5
Total lines: 207
Line coverage: 80%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Cyclomatic complexity NPath complexity Sequence coverage
.ctor(...)100%11100%

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{
 14    private readonly List<ExperimentVariant> _variants = [];
 15    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)
 21    {
 22        _variants.Add(new ExperimentVariant(name, model ?? throw new ArgumentNullException(nameof(model)),
 23            policy ?? throw new ArgumentNullException(nameof(policy))));
 24        return this;
 25    }
 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)
 31    {
 32        _trafficSplit.Clear();
 33        _trafficSplit.AddRange(percentages ?? []);
 34        return this;
 35    }
 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)
 45    {
 46        cancellationToken.ThrowIfCancellationRequested();
 47        if (_variants.Count == 0)
 48            throw new InvalidOperationException("Add at least one variant with AddVariant.");
 49
 50        var split = NormalizeSplit(_trafficSplit, _variants.Count);
 51        var results = new List<ExperimentResult>();
 52
 53        foreach (var space in behaviorSpaces)
 54        {
 55            cancellationToken.ThrowIfCancellationRequested();
 56            var identity = GetSpaceIdentity(space);
 57            var variantIndex = SelectVariantByHash(identity, split);
 58            var variant = _variants[variantIndex];
 59            var intent = variant.Model.Infer(space);
 60            var decision = IntentPolicyEngine.Evaluate(intent, variant.Policy);
 61            results.Add(new ExperimentResult(variant.Name, identity, intent, decision));
 62        }
 63
 64        return Task.FromResult<IReadOnlyList<ExperimentResult>>(results);
 65    }
 66
 67    /// <summary>
 68    /// Synchronous batch run.
 69    /// </summary>
 70    public IReadOnlyList<ExperimentResult> Run(IReadOnlyList<BehaviorSpace> behaviorSpaces)
 71        => 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)
 81    {
 82        var groupA = results.Where(r => r.VariantName == variantA).ToList();
 83        var groupB = results.Where(r => r.VariantName == variantB).ToList();
 84
 85        if (groupA.Count == 0 || groupB.Count == 0)
 86            return new ExperimentSignificance(0, 1.0, false);
 87
 88        var allDecisions = groupA.Select(r => r.Decision)
 89            .Concat(groupB.Select(r => r.Decision))
 90            .Distinct()
 91            .ToList();
 92
 93        var chiSquare = 0.0;
 94        var totalA = (double)groupA.Count;
 95        var totalB = (double)groupB.Count;
 96        var total = totalA + totalB;
 97
 98        foreach (var decision in allDecisions)
 99        {
 100            var observedA = groupA.Count(r => r.Decision == decision);
 101            var observedB = groupB.Count(r => r.Decision == decision);
 102            var totalForDecision = observedA + observedB;
 103
 104            var expectedA = totalForDecision * (totalA / total);
 105            var expectedB = totalForDecision * (totalB / total);
 106
 107            if (expectedA > 0) chiSquare += Math.Pow(observedA - expectedA, 2) / expectedA;
 108            if (expectedB > 0) chiSquare += Math.Pow(observedB - expectedB, 2) / expectedB;
 109        }
 110
 111        var degreesOfFreedom = Math.Max(1, allDecisions.Count - 1);
 112        var pValue = ChiSquarePValue(chiSquare, degreesOfFreedom);
 113
 114        return new ExperimentSignificance(chiSquare, pValue, pValue < 0.05);
 115    }
 116
 117    private static string GetSpaceIdentity(BehaviorSpace space)
 118    {
 119        var entityId = space.GetMetadata<string>("entityId");
 120        if (!string.IsNullOrEmpty(entityId))
 121            return entityId;
 122
 123        var sessionId = space.GetMetadata<string>("sessionId");
 124        if (!string.IsNullOrEmpty(sessionId))
 125            return sessionId;
 126
 127        return string.Join("|", space.Events.Select(e => $"{e.Actor}:{e.Action}"));
 128    }
 129
 130    private static int SelectVariantByHash(string identity, int[] split)
 131    {
 132        var hash = StableHash(identity);
 133        var bucket = (int)(((uint)hash) % 100);
 134        var cumulative = 0;
 135        for (var i = 0; i < split.Length; i++)
 136        {
 137            cumulative += split[i];
 138            if (bucket < cumulative)
 139                return i;
 140        }
 141        return split.Length - 1;
 142    }
 143
 144    private static int StableHash(string input)
 145    {
 146        unchecked
 147        {
 148            var hash = 5381;
 149            foreach (var c in input)
 150                hash = ((hash << 5) + hash) + c;
 151            return hash;
 152        }
 153    }
 154
 155    private static int[] NormalizeSplit(List<int> requested, int variantCount)
 156    {
 157        if (requested.Count == variantCount && requested.Sum() == 100)
 158            return requested.ToArray();
 159        var equal = 100 / variantCount;
 160        var remainder = 100 % variantCount;
 161        var split = new int[variantCount];
 162        for (var i = 0; i < variantCount; i++)
 163            split[i] = equal + (i < remainder ? 1 : 0);
 164        return split;
 165    }
 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)
 171    {
 172        if (chiSquare <= 0 || degreesOfFreedom <= 0) return 1.0;
 173
 174        var k = degreesOfFreedom;
 175        var z = Math.Pow(chiSquare / k, 1.0 / 3) - (1.0 - 2.0 / (9.0 * k));
 176        z /= Math.Sqrt(2.0 / (9.0 * k));
 177
 178        var pValue = 0.5 * Erfc(z / Math.Sqrt(2));
 179        return Math.Clamp(pValue, 0, 1);
 180    }
 181
 182    private static double Erfc(double x)
 183    {
 184        var t = 1.0 / (1.0 + 0.5 * Math.Abs(x));
 185        var tau = t * Math.Exp(
 186            -x * x - 1.26551223 +
 187            t * (1.00002368 +
 188            t * (0.37409196 +
 189            t * (0.09678418 +
 190            t * (-0.18628806 +
 191            t * (0.27886807 +
 192            t * (-1.13520398 +
 193            t * (1.48851587 +
 194            t * (-0.82215223 +
 195            t * 0.17087277)))))))));
 196        return x >= 0 ? tau : 2 - tau;
 197    }
 198}
 199
 200/// <summary>
 201/// Result of statistical significance test between two experiment variants.
 202/// </summary>
 2203public sealed record ExperimentSignificance(
 0204    double ChiSquare,
 2205    double PValue,
 1206    bool IsSignificant
 2207);