Skip to content

Three stacking rules, one container

Two +50% sources: is that ×2 or ×2.25? Three rings of +5, +3 and +1: is that +9, or +5 because only the best one counts? RevFramework will not answer either question, and this page is the evidence that it does not have to — three genuinely different games' arithmetic, over a container, providers and consumers that none of them change.

Recipe

Systems required: Attributes. Package: Complete. Shape: one file of small components, any one of which you drop into an AttributeSet's combiner slot. Public API only. It assumes: nothing except an AttributeSet on the owner. Contributors are optional — a combiner with no providers is just base. Once you change it, it is your code. These are meant to be edited; the arithmetic is the part you are supposed to own.

The decision this page exists to demonstrate

AttributeSet computes an effective value as Clamp(combiner.Combine(base, contributions)), and there is nowhere inside the Attributes assembly to put a formula. No combiner ships. With none wired, contributions are not even collected, and the container logs one line saying so.

That is a load-bearing absence rather than an omission, and the argument is short: every published extension seam this framework already ships refuses the same canonicalisation. IDamageAffinity says outright that combination semantics are consumer-defined. IStatusResistance is a 0..1 scale that cannot express amplification. Shipping "obviously additive" here would contradict them, and would be irreversible — a named default becomes what everyone's saves and prefabs reference.

The evidence, not the assertion

The three rules below share nothing but the interface. Swapping one for another changes how every stat in the game stacks, and requires no change to any provider, to any consumer, to the container, or to a single authored attribute.

That is the claim worth checking, and it is why the page carries three rules instead of one good one.

Rule one: everything adds

base + Σ contributions. Tags are ignored — a contribution is a number, wherever it came from.

It is the rule most games start with, it is the easiest to reason about, and it is the one worked through with real contributors in a character sheet nothing writes to, which is why it appears here only as a function.

Rule two: flat first, then percentages

(base + Σ flat) × (1 + Σ percent). The split is by AttributeModifier.tag, which exists for precisely this: a taxonomy the container carries and never interprets.

Summed percentages and multiplied percentages are different games

Two +50% sources give ×2.0 when summed and ×2.25 when multiplied. Neither is more correct. Summing is predictable and a player can do it in their head; multiplying rewards stacking and runs away at the top end. This one sums, and swapping the loop is a one-line change in your copy.

Say which one you chose somewhere your designers will read it. The number of arguments this causes is not proportional to the size of the code.

Summing has a cliff at −100, and it is the one thing multiplying cannot do

Summed percentage points are an unbounded total. Three −40% debuffs are −120 points, so the multiplier is −0.2: a speed of 5 is served as −1, and the character walks backwards. Multiplying 0.6 × 0.6 × 0.6 cannot reach zero, let alone cross it — that floor belongs to the convention this page argues against, which is exactly why it is worth naming rather than assuming.

Stacking slows is a genre staple, so this is reachable rather than theoretical. The container repairs it only where the attribute has an authored min — an attribute created at runtime by SetBaseValue is unbounded by design, and the negative value is served as-is.

So author a minimum on any attribute a percentage rule governs, or floor the total in your copy of the loop. It is deliberately not floored here: a combiner that clamps is deciding a bound the authoring should own, which is the same argument the note below the code makes.

Untagged is flat, deliberately

A provider written before the taxonomy existed keeps working and is not silently reinterpreted as a percentage. Percentage points are the units: 10 is +10%.

Rule three: only the best buff and the worst debuff count

base + largest positive + most negative. Three rings of +5, +3, +1 give +5; a -4 curse still lands on top of it.

The asymmetry is the mechanic, and dropping it makes an exploit

"The largest contribution wins" applied to the whole list lets a +5 ring erase a -4 curse. It reads fine as a sentence and plays as a way to cancel debuffs by putting on jewellery.

This is the stat-sheet cousin of only your worst debuff counts, which makes the same argument one layer down: multiplicatively, against Health's damage pipeline, on a seam nothing else implements. The two are worth reading together — the shape of "contributions compete rather than accumulate" recurs, and the right place to put it depends entirely on what is being combined.

One combiner per owner, and the thing everybody tries first

An AttributeSet holds exactly one combiner, and it is asked about every attribute.

So the natural first move — an additive combiner on the character for might, a best-wins combiner beside it for resistance — does not work. AttributeSet is [DisallowMultipleComponent] and has one slot; a second combiner component on the object is simply never consulted, and nothing warns.

PerAttributeCombiner is the answer, and it is a switch: a table of attribute id → policy, with a fallback for everything unrouted.

The rule that is not a stacking rule at all

The fourth policy is Derived, and it is the reason to read this page even if you already knew what you wanted your percentages to do.

A combiner may read another attribute of the same owner from inside Combine. The container allocates fresh buffers for the nested read specifically so this works. Which means a derived stat needs no stored value, no update step, no dirty flag and no invalidation:

// carry = base + 2 × might, as a row in a table.
new PerAttributeCombiner.Route
{
    attributeId = "carry",
    policy      = PerAttributeCombiner.Policy.Derived,
    derivedFrom = "might",
    perPoint    = 2f,
}

It reads might's effective value, so a ring that raises might raises carry, through a code path that has never heard of rings. And it is correct the instant might changes for any reason at all, including reasons invented after the combiner was written.

The cycle that costs you is one line long

Derive carry from might and might from carry, and each read re-enters until AttributeSet.MaxReadDepth stops it — at which point the container warns once and serves clamped base values. It terminates and it tells you, which is the container doing its job.

It is still not something to ship. A stat that is quietly correct eight levels down and wrong at the ninth is worse than one that fails. A route that names itself is refused outright below, because that much is checkable; a two-step cycle is not, from inside one combiner.

It is the owner's chain, not the combiner's

The nested read goes through IAttributeSource, found with GetComponentInParent on the owner Combine is handed — the same discovery every optional capability in this framework uses. So the source may be the owner or any ancestor of it, and one combiner instance can serve every character in the game.

Resolving it from the combiner's own position instead is the trap worth naming, because it fails quietly and selectively: a shared combiner would read whichever character it happened to sit under — or nothing at all — while every additive, percentage and best-wins route on the same component carried on working perfectly.

Drop it in

HouseRules.cs
using System;
using System.Collections.Generic;

using RevGaming.RevFramework.Attributes.Abstractions;
using RevGaming.RevFramework.Attributes.UnityIntegration;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.HouseRules
{
    /// <summary>
    /// The arithmetic, as plain functions. Three genuinely different answers to "how do these
    /// contributions fold into a base value", none of which the framework will ever pick for you.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> Systems required: <b>Attributes</b>. Public API only. Static so the three
    /// components below can share them, and so a project can call one directly from a preview,
    /// a tooltip or a test without instantiating anything.</para>
    ///
    /// <para><b>None of them clamps.</b> The container applies the attribute's authored bounds
    /// <i>after</i> the combiner returns, so a combiner that clamps defensively narrows those bounds
    /// invisibly and makes an authored maximum unreachable. Every one of these can return something
    /// out of range on purpose; the container is what puts it back.</para>
    ///
    /// <para><b>All three are deterministic, allocation-free and non-throwing</b>, which is what the
    /// seam asks for — they run inside every effective read.</para>
    /// </remarks>
    public static class StackingRules
    {
        /// <summary>
        /// Everything adds. The simplest rule, and the one most games start with.
        /// </summary>
        /// <remarks>
        /// Tags are ignored entirely: a contribution is a number, wherever it came from. The
        /// worked example of this rule with real contributors is
        /// <see href="../SumOfParts/README.md">a character sheet nothing writes to</see>.
        /// </remarks>
        public static float Additive(float baseValue, IReadOnlyList<AttributeModifier> modifiers)
        {
            float total = baseValue;

            for (int i = 0; i < modifiers.Count; i++)
                total += modifiers[i].value;

            return total;
        }

        /// <summary>
        /// Flat bonuses apply first, then percentages apply to the result.
        /// </summary>
        /// <param name="baseValue">The owner's stored base value.</param>
        /// <param name="modifiers">Contributions, in provider order.</param>
        /// <param name="percentTag">
        /// The tag that marks a percentage contribution. Anything else — including an untagged
        /// contribution — is flat.
        /// </param>
        /// <remarks>
        /// <para><b>A percentage contribution is read as percentage points:</b> <c>10</c> means
        /// <c>+10%</c> and <c>-25</c> means <c>-25%</c>. That is a decision, not a convention the
        /// framework holds — the <see cref="AttributeModifier.tag"/> field exists precisely so a
        /// project can define a taxonomy the container never learns, and "the number after the sign
        /// is a percentage" is exactly such a taxonomy.</para>
        ///
        /// <para><b>Percentages are summed, not multiplied, and the difference is a different
        /// game.</b> Two <c>+50%</c> sources give <c>×2.0</c> here; multiplying them gives
        /// <c>×2.25</c>. Neither is more correct — summing is predictable and a player can do it in
        /// their head, while multiplying rewards stacking and runs away. This one sums, because the
        /// arithmetic should be arguable at the design table. Swap the loop if you want the other
        /// game.</para>
        ///
        /// <para><b>Summing has a cliff at −100 points, and it is the one thing multiplying cannot
        /// do.</b> The total is unbounded, so three −40% debuffs are −120 points and the multiplier
        /// is −0.2: a speed of 5 serves −1, and stacking slows is a genre staple rather than a
        /// contrived case. <c>0.6 × 0.6 × 0.6</c> cannot reach zero, let alone cross it. The
        /// container repairs this only where the attribute has an authored minimum — one created at
        /// runtime is unbounded by design — so author a <c>min</c> on any attribute a percentage
        /// rule governs, or floor the total here in your copy. It is not floored for you, because a
        /// combiner that clamps decides a bound the authoring should own.</para>
        ///
        /// <para><b>Untagged contributions are flat</b>, so a provider written before the taxonomy
        /// existed keeps working and is not silently reinterpreted as a percentage.</para>
        /// </remarks>
        public static float FlatThenPercent(float baseValue, IReadOnlyList<AttributeModifier> modifiers,
                                            string percentTag)
        {
            float flat = 0f;
            float percentPoints = 0f;

            for (int i = 0; i < modifiers.Count; i++)
            {
                AttributeModifier m = modifiers[i];

                // Ordinal, because every id in this framework is compared ordinally and a tag that
                // matches under one comparison and not another is the worst kind of bug to find.
                if (percentTag != null && string.Equals(m.tag, percentTag, StringComparison.Ordinal))
                    percentPoints += m.value;
                else
                    flat += m.value;
            }

            return (baseValue + flat) * (1f + percentPoints * 0.01f);
        }

        /// <summary>
        /// Only the largest bonus and the largest penalty apply. Everything else is ignored.
        /// </summary>
        /// <remarks>
        /// <para><b>Buffs compete; penalties do not cancel them.</b> Three rings of +5, +3 and +1
        /// give +5, not +9 — but a −4 curse still lands on top, for −4. This is the stat-sheet
        /// cousin of <see href="../WorstDebuffWins/README.md">only your worst debuff counts</see>,
        /// which makes the same argument one layer down and multiplicatively, against Health's
        /// damage pipeline.</para>
        ///
        /// <para><b>Both halves are needed and the asymmetry is deliberate.</b> "The largest
        /// contribution wins" applied to the whole list would let a +5 ring erase a −4 curse, which
        /// is the sort of rule that reads fine and plays as an exploit.</para>
        ///
        /// <para>A contribution of exactly zero changes nothing under this rule, as under the
        /// others.</para>
        /// </remarks>
        public static float BestBuffWorstDebuff(float baseValue, IReadOnlyList<AttributeModifier> modifiers)
        {
            float bestBonus = 0f;
            float worstPenalty = 0f;

            for (int i = 0; i < modifiers.Count; i++)
            {
                float v = modifiers[i].value;

                if (v > bestBonus) bestBonus = v;
                else if (v < worstPenalty) worstPenalty = v;
            }

            return baseValue + bestBonus + worstPenalty;
        }
    }

    /// <summary>
    /// Flat bonuses first, then percentages. One inspector slot's worth of house rule.
    /// </summary>
    /// <remarks>
    /// Wire it into the <c>AttributeSet</c>'s combiner slot, or hand it over with
    /// <c>AttributeSet.SetCombiner</c> from code. Swapping it for
    /// <see cref="BestBuffWorstDebuffCombiner"/> changes how every stat in the game stacks and
    /// requires no change to any provider, any consumer, or the container.
    /// </remarks>
    [DisallowMultipleComponent]
    [AddComponentMenu("RevFramework/Cookbook/House Rules/Flat Then Percent")]
    public sealed class FlatThenPercentCombiner : MonoBehaviour, IAttributeCombiner
    {
        [Tooltip("Contributions carrying this tag are percentages, in points: 10 means +10%. " +
                 "Anything else, including an untagged contribution, is a flat bonus. Compared ordinally.")]
        [SerializeField] private string percentTag = "percent";

        /// <inheritdoc />
        public float Combine(GameObject owner, string attributeId, float baseValue,
                             IReadOnlyList<AttributeModifier> modifiers)
            => StackingRules.FlatThenPercent(baseValue, modifiers, percentTag);
    }

    /// <summary>
    /// The largest bonus and the largest penalty apply; the rest are scenery.
    /// </summary>
    /// <remarks>
    /// The same wiring as <see cref="FlatThenPercentCombiner"/>, and that is the point of the page:
    /// two games whose stat arithmetic has nothing in common, over an unchanged container.
    /// </remarks>
    [DisallowMultipleComponent]
    [AddComponentMenu("RevFramework/Cookbook/House Rules/Best Buff Worst Debuff")]
    public sealed class BestBuffWorstDebuffCombiner : MonoBehaviour, IAttributeCombiner
    {
        /// <inheritdoc />
        public float Combine(GameObject owner, string attributeId, float baseValue,
                             IReadOnlyList<AttributeModifier> modifiers)
            => StackingRules.BestBuffWorstDebuff(baseValue, modifiers);
    }

    /// <summary>
    /// One rule per attribute — and the answer to the thing everybody tries first.
    /// </summary>
    /// <remarks>
    /// <para><b>An owner has exactly one combiner, and it is asked about every attribute.</b> That
    /// is the fact this component exists for. The natural first instinct — put an additive combiner
    /// on the character for <c>might</c> and a best-wins combiner beside it for <c>resistance</c> —
    /// does not work. <c>AttributeSet</c> is <c>[DisallowMultipleComponent]</c> and holds one
    /// combiner — an inspector slot, or whatever <c>SetCombiner</c> last supplied — so the second
    /// combiner component on the object is simply never consulted, and nothing says so. Routing by
    /// id inside one combiner is how a project gets per-attribute rules, and it is a
    /// <c>switch</c>.</para>
    ///
    /// <para><b>The <see cref="Policy.Derived"/> row is the non-obvious one.</b> A combiner may read
    /// another attribute of the same owner from inside <c>Combine</c> — the container allocates
    /// fresh buffers for the nested read specifically so this works — which means a derived stat
    /// needs no stored value, no update step and no invalidation. <c>carry = base + 2 × might</c>
    /// is a row in a table, and it is correct the instant <c>might</c> changes for any reason at
    /// all, including a reason invented after this component was written.</para>
    ///
    /// <para><b>The nested read goes through the owner <c>Combine</c> is handed</b>, not through
    /// this component's own chain, so one instance of this combiner can serve every character in
    /// the game. That is worth being deliberate about: a combiner wired into several
    /// <c>AttributeSet</c>s is a natural way to express a house rule, and a <see cref="Policy.Derived"/>
    /// row that resolved from the combiner's own position would read the wrong character — or
    /// nothing — while every other row kept working.</para>
    ///
    /// <para><b>The cycle that costs you is one line long.</b> Derive <c>carry</c> from
    /// <c>might</c> and <c>might</c> from <c>carry</c>, and each read re-enters until
    /// <c>AttributeSet.MaxReadDepth</c> stops it, whereupon the container warns once and serves
    /// clamped base values. It terminates and it tells you — but a stat that is quietly correct
    /// eight levels down and wrong at the ninth is worth not writing. A row that names itself is
    /// refused outright below, because that much is checkable.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    [AddComponentMenu("RevFramework/Cookbook/House Rules/Per Attribute")]
    public sealed class PerAttributeCombiner : MonoBehaviour, IAttributeCombiner
    {
        /// <summary>Which arithmetic one attribute uses.</summary>
        public enum Policy
        {
            /// <summary>Every contribution adds.</summary>
            Additive = 0,

            /// <summary>Flat bonuses first, then percentages.</summary>
            FlatThenPercent = 1,

            /// <summary>Largest bonus and largest penalty only.</summary>
            BestBuffWorstDebuff = 2,

            /// <summary>Additive, plus a multiple of another attribute's effective value.</summary>
            Derived = 3,
        }

        /// <summary>One attribute's rule.</summary>
        [Serializable]
        public struct Route
        {
            [Tooltip("Attribute id this rule applies to. Compared ordinally.")]
            public string attributeId;

            [Tooltip("The arithmetic this attribute uses.")]
            public Policy policy;

            [Tooltip("Derived only: the attribute read as the source. Its effective value is used, " +
                     "so contributions to it count.")]
            public string derivedFrom;

            [Tooltip("Derived only: how much of the source attribute each point is worth.")]
            public float perPoint;
        }

        [Tooltip("Per-attribute rules. An attribute with no row here uses the fallback below.")]
        [SerializeField] private Route[] routes = Array.Empty<Route>();

        [Tooltip("The rule for every attribute with no row of its own. Derived is not a valid " +
                 "fallback — it names a source, and a fallback has no attribute to name one for.")]
        [SerializeField] private Policy fallback = Policy.Additive;

        [Tooltip("Tag marking a percentage contribution, for attributes using FlatThenPercent.")]
        [SerializeField] private string percentTag = "percent";

        private GameObject _sourceOwner;
        private IAttributeSource _source;
        private bool _warnedSelfReference;
        private bool _warnedDerivedFallback;

        /// <inheritdoc />
        public float Combine(GameObject owner, string attributeId, float baseValue,
                             IReadOnlyList<AttributeModifier> modifiers)
        {
            for (int i = 0; i < routes.Length; i++)
            {
                if (!string.Equals(routes[i].attributeId, attributeId, StringComparison.Ordinal))
                    continue;

                return Apply(routes[i].policy, in routes[i], owner, attributeId, baseValue, modifiers);
            }

            if (fallback == Policy.Derived)
            {
                // A fallback of Derived has no source to read, so it can only ever mean Additive.
                // Saying so once beats behaving as Additive and letting someone believe otherwise.
                if (!_warnedDerivedFallback)
                {
                    _warnedDerivedFallback = true;
                    Debug.LogWarning($"[{nameof(PerAttributeCombiner)}] Derived is not a usable " +
                                     "fallback: it needs a source attribute, which only a route can " +
                                     "name. Treating unrouted attributes as Additive.", this);
                }

                return StackingRules.Additive(baseValue, modifiers);
            }

            return Apply(fallback, default, owner, attributeId, baseValue, modifiers);
        }

        private float Apply(Policy policy, in Route route, GameObject owner, string attributeId,
                            float baseValue, IReadOnlyList<AttributeModifier> modifiers)
        {
            switch (policy)
            {
                case Policy.FlatThenPercent:
                    return StackingRules.FlatThenPercent(baseValue, modifiers, percentTag);

                case Policy.BestBuffWorstDebuff:
                    return StackingRules.BestBuffWorstDebuff(baseValue, modifiers);

                case Policy.Derived:
                    return StackingRules.Additive(baseValue, modifiers)
                           + route.perPoint * SourceValue(owner, route.derivedFrom, attributeId);

                default:
                    return StackingRules.Additive(baseValue, modifiers);
            }
        }

        /// <summary>
        /// The effective value of the attribute a derived row reads, or zero when there is none.
        /// </summary>
        /// <remarks>
        /// <para><b>Effective, not base</b> — a derived stat that ignored contributions to its source
        /// would be a stat that stopped responding to the player's gear, which is the one thing it
        /// exists to do.</para>
        ///
        /// <para>A missing source attribute contributes nothing rather than refusing the read.
        /// <c>TryGetValue</c> is total by contract: it returns false for an unknown id and never
        /// throws, so an attribute that does not exist yet on a partially-built character is a
        /// temporary zero rather than an exception inside a read.</para>
        /// </remarks>
        private float SourceValue(GameObject owner, string sourceId, string attributeId)
        {
            if (string.IsNullOrWhiteSpace(sourceId))
                return 0f;

            // Checkable half of the cycle problem. The uncheckable half -- A derives B derives A --
            // is stopped by AttributeSet.MaxReadDepth, which warns and serves base values.
            if (string.Equals(sourceId, attributeId, StringComparison.Ordinal))
            {
                if (!_warnedSelfReference)
                {
                    _warnedSelfReference = true;
                    Debug.LogWarning($"[{nameof(PerAttributeCombiner)}] '{attributeId}' is routed as " +
                                     "Derived from itself, which would recurse until " +
                                     $"AttributeSet.MaxReadDepth ({AttributeSet.MaxReadDepth}) stopped " +
                                     "it. The derived term is treated as zero.", this);
                }

                return 0f;
            }

            IAttributeSource source = SourceFor(owner);

            return source != null && source.TryGetValue(sourceId, out float value) ? value : 0f;
        }

        /// <summary>
        /// The attribute source of the owner being combined, cached for the owner it belongs to.
        /// </summary>
        /// <remarks>
        /// <para><b>Resolved from the owner, never from this component</b>, and the difference is
        /// the whole of whether a shared combiner works. A combiner is ordinary C# that one
        /// instance can serve every character with — the inspector slot and <c>SetCombiner</c> both
        /// accept a component from anywhere, and the two combiners above are pure functions with
        /// nothing to share wrongly. Walking <i>this</i> component's chain instead would read
        /// whichever character the combiner happens to sit under, or nothing at all, while every
        /// non-derived route carried on working perfectly — a defect that only appears when
        /// somebody adds a <see cref="Policy.Derived"/> row.</para>
        ///
        /// <para>The cache holds one owner, which is the whole of the ordinary case. A combiner
        /// serving several owners re-resolves as they alternate, at the cost of the
        /// <c>GetComponentInParent</c> the container already runs for its providers on every read.
        /// Inactive objects are included, matching how the container collects providers, so a
        /// character assembled switched-off derives the same values as one in a live scene.</para>
        /// </remarks>
        private IAttributeSource SourceFor(GameObject owner)
        {
            if (!owner)
                return null;

            // Interface-typed, so a destroyed component has to be dropped explicitly: Unity's
            // destroyed-object reporting never runs behind a plain null check on an interface.
            if (_source is UnityEngine.Object dead && !dead)
                _source = null;

            // Deliberately an if rather than ??=: the null-coalescing operators are plain reference
            // checks that a destroyed UnityEngine.Object passes, which is why the liveness check
            // above has to be its own statement.
            if (_source != null && ReferenceEquals(_sourceOwner, owner))
                return _source;

            _sourceOwner = owner;
            _source = owner.GetComponentInParent<IAttributeSource>(includeInactive: true);

            return _source;
        }
    }
}

Wiring it up

  1. Pick one combiner and drop it into the AttributeSet's combiner slot. That is the whole of the wiring. SetCombiner does the same from code and takes precedence while it is set. One instance can serve any number of characters — a house rule is usually one object in the scene, and the derived source is resolved per owner.
  2. For FlatThenPercentCombiner, decide what your percent tag is called and have your providers pass it: new AttributeModifier(10f, "percent").
  3. For PerAttributeCombiner, add a route per attribute that needs its own rule and leave the rest to the fallback. Derived is not a usable fallback — it needs a source attribute, which only a route can name — and it says so once if you try.
  4. Read StackingRules directly from a tooltip or a preview if you want to show the arithmetic without going through a read. They are static and side-effect free.

None of them clamps, and that is not an omission

The container applies the attribute's authored bounds after the combiner returns. A combiner that clamps defensively narrows those bounds invisibly and makes an authored maximum unreachable.

The same three rules, in a game with no characters in it

Attributes knows nothing about strength, dexterity or character classes, and neither do these combiners: every one of them is a function over (float, list of floats, tags). Read the identical file as the control model for a drill rig and nothing changes but the ids:

Attribute What contributes Rule that suits it
feedRate installed drill head, operator skill, ground hardness flat then percent — the head is a fixed rating, the ground is a proportion
powerDraw every running subsystem additive — draws sum, because that is what power does
structuralLimit the worst-worn component, plus a reinforcement best buff / worst debuff — a rig is as strong as its weakest member and one brace helps
effectiveDepth derived from feedRate Derived — a computed read, never a stored number

Not one line of the file changes to serve that table, which is the same claim the page opened with, made for a vocabulary the framework has certainly never anticipated. If your project's numbers are heat and wear rather than might and vitality, nothing here is metaphorical.

That last row is worth reading precisely, because it marks where the claim stops. Derived is base + perPoint × one source attribute — linear, one input, a constant coefficient. So "effectiveDepth follows feedRate" is a row in a table; "depth is rate times elapsed time" is two varying inputs multiplied together, and that needs a case of your own in Apply. The policy is a table entry, not an expression language, and the boundary is exactly there.

What it deliberately does not do

It does not offer an authored formula language. A curve asset, an expression parser and a designer-facing calculator are all reasonable things to want and all of them are a project's, not a framework's. Combine is C# because C# is what the project already has.

It does not distinguish contributors. The combiner sees values and tags, never sources. "The strongest curse wins" and "the strongest anything wins" are the same sentence here — per-source rules need a richer contribution than the seam carries, and the place to build one is the tag.

It does not cache. A combiner runs inside every effective read, and so does every provider on the chain. All three of these are O(contributions) with no allocation, which is the budget the seam asks for.

It does not persist anything. A combiner is policy, not state. Attributes saves base values; what your rules do with them is recomputed every read, which is why swapping the rule after a save loads is safe.