Skip to content

Merging

All domain entities — Scope, UserStory, and AcceptanceCriterion — inherit from MergeableModel (provided by the pleroma library). This gives them controlled merge semantics: a child entity overrides a parent entity field by field.

Scalar field merging

For any simple field (str, bool, int, Enum), the child's value replaces the parent's:

from fushinryu_model import AcceptanceCriterion

parent = AcceptanceCriterion(id=1, given="a user is logged in", then="the dashboard is shown")
child  = AcceptanceCriterion(id=1, given=None, then="the profile is shown")

merged = AcceptanceCriterion.merge([parent, child])
# merged.given == "a user is logged in"   ← parent kept (child was None)
# merged.then  == "the profile is shown"  ← child wins

None values from the child do not override a non-None parent value unless overwrite_none=True is passed explicitly.

Collection merging with merge_by_id

Collections of entities (acceptance criteria within a user story, user stories within a scope) are merged using the internal merge_by_id utility: entities are keyed by their integer id; overlapping ids are merged recursively; non-overlapping entities from both sets are included as-is.

from fushinryu_model import AcceptanceCriterion, UserStory, UserStoryType

parent_story = UserStory(
    id=1, who="developer", what="X", why="Y", type=UserStoryType.FUNCTIONAL,
    acceptance_criteria=frozenset([
        AcceptanceCriterion(id=1, then="original condition"),
        AcceptanceCriterion(id=2, then="only in parent"),
    ]),
)
child_story = UserStory(
    id=1, who="developer", what="X", why="Y", type=UserStoryType.FUNCTIONAL,
    acceptance_criteria=frozenset([
        AcceptanceCriterion(id=1, then="overridden condition"),  # same id → merged
        AcceptanceCriterion(id=3, then="only in child"),          # new id → added
    ]),
)

merged = UserStory.merge([parent_story, child_story])
# AC id=1 → "overridden condition"
# AC id=2 → "only in parent"
# AC id=3 → "only in child"

Validation record merging

Validation records follow different rules depending on their type.

Type Merge behaviour
ManualValidation All records from both parent and child are accumulated
AutomatedValidation Deduplicated by (source, name); the record with the most recent timestamp wins

This means manual review evidence is never discarded, while automated test results are automatically superseded by their latest run.

Rule category merging

Scope.rule_categories follows different rules from other collections because categories form a tree, not a flat list keyed by integer id.

Tree-position matching

When two scopes are merged, categories are matched by their name string at each level. Categories present in only one scope are included unchanged. Categories present in both are merged recursively.

from fushinryu_model import Category, Commandment, Ruleset, Scope

c1 = Commandment(body="Rule from parent")
c2 = Commandment(body="Rule from child")
cat_parent = Category(name="quality", applicability="When writing code",
                      ruleset=Ruleset(commandments=frozenset({c1})))
cat_child  = Category(name="quality", applicability="When writing code",
                      ruleset=Ruleset(commandments=frozenset({c2})))

parent = Scope(id="base", name="Base", rule_categories=frozenset({cat_parent}))
child  = Scope(id="ext",  name="Ext",  rule_categories=frozenset({cat_child}),
               parents=(parent,))

flat = child.collapse()
merged_cat = next(c for c in flat.rule_categories if c.name == "quality")
# merged_cat.ruleset.commandments == {c1, c2}  ← union of both

Commandment merging

Commandments from both parent and child are always accumulated (set union, deduplicated by body). A commandment appearing in both scopes is kept once.

Suggestion merging

Suggestion merging depends on the child category's additive flag:

additive value Behaviour
False (default) Child's suggestions replace the parent's entirely
True Child's suggestions are unioned with the parent's (deduplicated by body)
s_parent = Suggestion(body="Consider X")
s_child  = Suggestion(body="Consider Y")

# additive=False (default): child replaces parent
cat_p = Category(name="style", applicability="Always",
                 ruleset=Ruleset(suggestions=frozenset({s_parent})))
cat_c = Category(name="style", applicability="Always", additive=False,
                 ruleset=Ruleset(suggestions=frozenset({s_child})))
# merged suggestions → {s_child} only

# additive=True: union
cat_c_additive = Category(name="style", applicability="Always", additive=True,
                          ruleset=Ruleset(suggestions=frozenset({s_child})))
# merged suggestions → {s_parent, s_child}

Subcategory merging

Subcategories within a category are merged by the same tree-position rules, applied recursively. The full rule-category tree is therefore merged in a single recursive pass during Scope.collapse().

Role merging

Scope.roles is a frozenset[Role] matched by Role.name. When two scopes are merged:

  • Roles present in only one scope are included unchanged.
  • Roles present in both scopes are linked: the child's role receives the parent scope's role as its parent field, establishing an explicit hierarchy chain.

Scope.collapse() calls Role.flatten() on every role in the result, resolving and removing all parent chains. The leaf role's field values are preserved as the effective values.

from fushinryu_model import Role, Scope

r_parent = Role(name="developer", description="Base developer")
r_child  = Role(name="developer", description="Senior developer")

parent = Scope(id="base", name="Base", roles=frozenset({r_parent}))
child  = Scope(id="ext",  name="Ext",  roles=frozenset({r_child}), parents=(parent,))

flat = child.collapse()
role = next(r for r in flat.roles if r.name == "developer")
# role.description == "Senior developer"  ← child value preserved
# role.parent is None                      ← chain flattened

Role competence merging

RoleCompetence bindings within a role use max-value semantics. When the same competence appears in both parent and child role:

  • relevance → the higher of the two values wins.
  • proficiency_threshold → the higher of the two values wins.

The two fields are evaluated independently. This guarantees child scopes can only raise, never lower, inherited requirements.

Role assignment merging

Scope.role_assignments uses child-override semantics keyed by the (employee.id, role.name) pair. When the same pair appears in both parent and child scope, the child's RoleAssignment replaces the parent's entirely. Assignments present in only one scope are included unchanged.

Procedure merging

Scope.procedures uses child-override semantics keyed by Procedure.id. Procedures are non-mergeable: when the same id appears in both parent and child scope, the child's Procedure — its entire node graph — replaces the parent's wholesale. There is no field-level or node-level merging, unlike RoleAssignment's field-preserving replacement. Procedures present in only one scope are included unchanged.

from fushinryu_model import EndTerminatorNode, Procedure, Scope, StartTerminatorNode

def _linear(description: str) -> Procedure:
    return Procedure(
        id="review",
        description=description,
        nodes=frozenset({
            StartTerminatorNode(id="s", description="start", next="e"),
            EndTerminatorNode(id="e", description="end"),
        }),
    )

parent = Scope(id="base", name="Base", procedures=frozenset({_linear("base version")}))
child  = Scope(id="ext",  name="Ext",  procedures=frozenset({_linear("ext version")}), parents=(parent,))

flat = child.collapse()
# next(iter(flat.procedures)).description == "ext version"  ← child replaces parent wholesale

When merging is used

Merging is the mechanism that powers scope hierarchy collapse. When Scope.collapse() is called, every scope in the ancestry is merged from lowest to highest precedence, producing a single flat scope with all inherited stories and criteria resolved.