RunMaps API Reference
Run maps: runs joined by labeled links, and functions that build them for common control and duplicate layouts. See Run Maps for a worked example. The main entry points are RunMap, link!, linked_runs, and schedule_uniform_controls.
RunMaps.group_solvers — Constant
group_solversPredefined group-assignment solvers for schedule_uniform_controls. Maps a solver name to a function (n::Int, capacity::Int; kwargs...) -> Vector{Int}.
DataFrames.DataFrame — Method
DataFrame(map::RunMap) -> DataFrameOne row per edge, with columns run, linked_run, relation_type::Symbol, and metadata::Dict{Symbol,Any} (an empty Dict where none was set, never missing).
See also: RunMap(::DataFrames.AbstractDataFrame).
RunMaps.RunMap — Type
RunMap{N}A multimap over a single pool of nodes of type N, expressing directed, typed relationships between them (e.g. "run X is validated by control Y", "run X is a duplicate of run Y"). Each edge is labeled with an open/extensible Symbol relation_type (e.g. :positive, :negative, :blank, :duplicate, or any caller-defined label) and may carry optional metadata.
Any node may simultaneously be the subject of some edges and the linked node of others – role is purely relative to a given edge, never a node-level property. N is opaque and caller-supplied – RunMap has no notion of what a node represents.
See also: link!, linked_runs, linking_runs, runs.
RunMaps.RunMap — Method
RunMap(d::AbstractDict) -> RunMapReconstruct a RunMap from a dict of the shape produced by JSON.lower(::RunMap)/json_to_runmap: a top-level "runs" node list (so orphan nodes round-trip) plus an "edges" list of {"run","linked_run","relation_type","metadata"} objects.
Since JSON has no native type system beyond string/number/bool/array/object, a node that was e.g. a Symbol or UUID before serialization comes back as a String – N is inferred from the parsed values' actual types, same as the DataFrame reverse constructor. The one exception is a (row=, copy=) composite node (used for duplicated-well identifiers), which parses to a Dict("row"=>..,"copy"=>..) with no residual type information; that shape is special-cased back into a NamedTuple so it round-trips correctly.
RunMaps.RunMap — Method
RunMap(df::DataFrames.AbstractDataFrame; run_col=:run, linked_run_col=:linked_run,
type_col=:relation_type, metadata_col=:metadata) -> RunMapReverse of DataFrame(::RunMap) – reconstructs a RunMap by link!-ing every row. N is promote_type(eltype(run_col), eltype(linked_run_col)); falls back to Any when the two columns don't share a common concrete type (a single node pool can't be Int-and-String-but-not-Any). metadata_col defaults to :metadata to match DataFrame(::RunMap)'s output column, so the two are round-trip compatible by default; pass metadata_col=nothing to ignore a metadata column.
RunMaps.add_run! — Method
add_run!(map::RunMap, run) -> mapRegister run as a node with no edges. Idempotent – a no-op if run is already registered. The only registration function: every node, regardless of what role(s) it ends up playing in any edge, is a "run."
RunMaps.assign_groups_MILP — Method
assign_groups_MILP(n::Int, capacity::Int; kwargs...) -> Vector{Int}Convenience overload for the unweighted/homogeneous case (every item has weight 1) – equivalent to assign_groups_MILP(fill(1, n), capacity; kwargs...).
RunMaps.assign_groups_MILP — Method
assign_groups_MILP(weights::Vector{Int}, capacity::Int; optimizer=_default_optimizer[], timelimit=100) -> Vector{Int}MILP formulation of the same weighted group-assignment problem as assign_groups_greedy: partition items (given by weights) into the minimum number of groups whose total weight never exceeds capacity. Unlike the greedy first-fit-decreasing heuristic, this is exact (given enough time) for general/mixed weights.
The candidate group count G is sized from running greedy first as a valid upper bound (G = length(unique(assign_groups_greedy(weights, capacity)))), since cld(n, capacity) is only a valid bound for uniform weights.
Throws ArgumentError if any weight exceeds capacity.
Keyword Arguments
optimizer: the JuMP-compatible optimizer used to solve the model, defaulting toGurobi.Optimizer(requires a Gurobi license). A license-free alternative such asHiGHS.Optimizercan be passed instead.timelimit: time limit in seconds for the solver.
RunMaps.assign_groups_greedy — Method
assign_groups_greedy(n::Int, capacity::Int; kwargs...) -> Vector{Int}Convenience overload for the unweighted/homogeneous case (every item has weight 1) – equivalent to assign_groups_greedy(fill(1, n), capacity; kwargs...).
RunMaps.assign_groups_greedy — Method
assign_groups_greedy(weights::Vector{Int}, capacity::Int; kwargs...) -> Vector{Int}Partition items (given by weights, e.g. cluster sizes) into groups whose total weight never exceeds capacity, using first-fit-decreasing (FFD): sort items by weight descending, place each into the first group with enough remaining capacity, opening a new group otherwise.
For uniform weights (e.g. every item weight 1) FFD still fills each group to capacity before opening the next, so it achieves the exact optimal group count cld(n, capacity) – a strict generalization of the previous unweighted behavior, not a change to it. For general/mixed weights FFD is a well-known good heuristic, not guaranteed exact – see assign_groups_MILP for an exact (given enough time) alternative.
Throws ArgumentError if any weight exceeds capacity (an item that can never fit in any group). Accepts and ignores extra kwargs so it is interchangeable with assign_groups_MILP behind group_solvers.
RunMaps.component_sizes — Method
component_sizes(map::RunMap) -> Vector{Int}Size (total node count) of each connected component in map, equivalent to n_nodes.(components(map)) but computed without materializing the component sub-maps.
RunMaps.components — Method
components(map::RunMap{N}) -> Vector{RunMap{N}}Connected components of map, treated as an undirected graph over its single node pool (an edge of any relation_type connects its two endpoints regardless of direction). Each component is returned as an independent RunMap{N} containing exactly that component's nodes, edges (with their original relation_type labels), and edge metadata.
A node registered via add_run! but never linked to anything forms its own singleton component of size 1.
map is not mutated, and the returned maps share no mutable state with it. Order of the returned vector is unspecified.
See also: n_components, n_nodes, component_sizes.
RunMaps.default_duplicate_id_factory — Method
default_duplicate_id_factory(run, replicate_index::Int) -> SymbolDefault duplicate_id_factory for schedule_duplicates. Produces a readable, debuggable Symbol of the form RUN_dupREPLICATE, e.g. :A_dup1, :A_dup2.
RunMaps.default_linked_run_id_factory — Method
default_linked_run_id_factory(group_index::Int, role::Symbol, replicate_index::Int) -> SymbolDefault linked_run_id_factory for schedule_uniform_controls. Produces a readable, debuggable Symbol of the form role_gGROUP_REPLICATE, e.g. :positive_g1_1, :positive_g1_2, :negative_g3_1.
group_index is the group's 1-based position in the canonical, contiguously renumbered group ordering used by schedule_uniform_controls, not necessarily any raw id returned by the underlying group-assignment solver. replicate_index ranges over 1:role_counts[role] within that group/role.
RunMaps.edge_metadata — Method
edge_metadata(map::RunMap, run, linked_run, relation_type) -> Union{Nothing,Dict{Symbol,Any}}Metadata attached to the (run, linked_run, relation_type) edge, or nothing if that edge doesn't exist. An edge with no metadata set returns an empty Dict, distinguishing "no edge" from "edge with no metadata."
RunMaps.edges — Method
edges(map::RunMap) -> Vector{<:NamedTuple}All edges as (run, linked_run, relation_type, metadata) named tuples.
RunMaps.has_link — Method
has_link(map::RunMap, run, linked_run; type=nothing) -> BoolWhether an edge exists from run to linked_run, optionally restricted to one relation_type.
RunMaps.has_run — Method
has_run(map::RunMap, run) -> BoolWhether run is a registered node.
RunMaps.json_to_runmap — Method
json_to_runmap(s::AbstractString) -> RunMapParse a JSON string (produced by runmap_to_json) back into a RunMap.
RunMaps.link! — Method
link!(map::RunMap, run, linked_run, relation_type; metadata=nothing) -> mapCreate an edge from run to linked_run labeled relation_type (a Symbol or AbstractString, canonicalized to Symbol). Implicitly registers run and linked_run if they aren't already known.
Idempotent on graph structure – relinking an existing edge does not duplicate it. If metadata is given, it is merged into the edge's existing metadata rather than replacing it, so a repeated link! call can't silently drop metadata a previous call attached.
RunMaps.linked_runs — Method
linked_runs(map::RunMap, run; type=nothing) -> VectorNodes linked from run (i.e. run is the subject of the edge), optionally filtered to one relation_type. Returns an empty vector if run is unregistered or has no matching edges (unknown nodes are treated as having zero edges, not an error).
RunMaps.linking_runs — Method
linking_runs(map::RunMap, linked_run; type=nothing) -> VectorRuns for which linked_run is the linked node, optionally filtered to one relation_type. Returns an empty vector if linked_run is unregistered or has no matching edges.
RunMaps.n_components — Method
n_components(map::RunMap) -> IntNumber of connected components in map (see components). Computed without materializing the component sub-maps.
RunMaps.n_edges — Method
n_edges(map::RunMap) -> IntNumber of edges in the graph.
RunMaps.n_nodes — Method
n_nodes(map::RunMap) -> IntTotal number of registered nodes in map. An alias for n_runs, kept as a separate name for capacity-accounting call sites (e.g. "how many wells does this component need") that don't want to imply every node is semantically a "run."
RunMaps.n_runs — Method
n_runs(map::RunMap) -> IntNumber of registered nodes.
RunMaps.read_runmap_json — Method
read_runmap_json(path::AbstractString) -> RunMapRead a RunMap previously written with write_json.
RunMaps.relation_types — Method
relation_types(map::RunMap, run) -> Vector{Symbol}Distinct relation types linked from run.
RunMaps.remove_run! — Method
remove_run!(map::RunMap, run) -> mapDeregister run, cascading to unlink every edge that touches it – both edges where run is the subject (forward[run]) and edges where run is the linked node of some other run (backward[run]), since a node's edges are no longer segregated by role. No-op if run was never registered.
RunMaps.roles — Method
roles(map::RunMap, linked_run) -> Vector{Symbol}Distinct relation types (roles) linked_run plays, across the runs that link to it. Generic across relation kinds – e.g. a :duplicate relation's "role" reads just as naturally as a control's role.
RunMaps.roles — Method
roles(map::RunMap) -> Vector{Symbol}Every distinct relation type used anywhere in map (e.g. [:positive, :negative, :duplicate]), regardless of which nodes carry them. Empty if map has no edges.
RunMaps.runmap_to_json — Method
RunMaps.runs — Method
runs(map::RunMap) -> VectorEvery registered node (the single pool).
RunMaps.schedule_controls! — Method
schedule_controls!(map::RunMap, active_runs::Vector, control_pools::AbstractDict,
role_counts::AbstractDict, cap::Int;
solver::String="greedy", kwargs...) -> mapExtend map in place with "pooled controls" scheduling: partition active_runs into groups capped at cap total nodes each (matching n_nodes/component_sizes), and within each group, claim an exclusive subset of each role's supplied control pool (control_pools[role], sized by role_counts[role]) and link! every run in the group to every control claimed for that group (dense within the group, same semantics as schedule_uniform_controls). Since linking is dense, each group's role_counts values are the guaranteed minimum number of controls (per role) every run in that group receives.
Controls are claimed exclusively per group (never shared across groups), so each resulting group remains its own connected component, the same disjoint-component property schedule_uniform_controls's groups have. control_pools itself is never mutated – an internal cursor tracks how much of each role's pool has been claimed.
Cluster-aware partitioning: nodes among active_runs that are already connected via edges map has (e.g. :duplicate edges from schedule_duplicates) are grouped as one atomic unit – the partitioner never splits an existing cluster across groups, so a group's size and connected-component identity are preserved exactly as intended even when chaining after a stage that already created edges. This is implemented by treating each cluster's size as its weight in a weighted bin-packing problem (see assign_groups_greedy/assign_groups_MILP). With no prior edges among active_runs (the common non-chained case), every cluster is a singleton and this reduces to the original unweighted partitioning.
A new function alongside (not replacing) schedule_uniform_controls: this one draws controls from a caller-supplied, finite pool of existing runs (e.g. runs meant to serve as controls) and extends an already-built map – the mechanism that makes it possible to chain after schedule_duplicates, e.g. schedule_controls!(schedule_duplicates(runs, 2), ...) (after capturing active_runs = runs(map) from the duplicate stage's output).
Arguments
map: existingRunMapto extend, e.g. the output ofschedule_duplicates.active_runs: the runs that need controls (e.g.runs(map)right afterschedule_duplicates, before anything else has been added).control_pools:Dict{Symbol,Vector}mapping role -> the pool of existing run ids available to serve that role.role_counts: number of controls to claim per role per group, e.g.Dict(:positive=>1, :negative=>1). Same validation asschedule_uniform_controls: non-empty, positive values,Symbol/AbstractStringkeys canonicalized toSymbol, no duplicates after canonicalization. Every role must also have a matching key incontrol_pools.cap: maximum total node count (runs + controls) of any resulting group/component. Must exceedsum(values(role_counts)), and no existing cluster amongactive_runsmay exceedcap - sum(values(role_counts))(elseArgumentError– a cluster too large to fit in any group cannot be silently split).
Keyword Arguments
solver: group-assignment algorithm,"greedy"(default) or"MILP".kwargs...: forwarded to the chosen solver.
Throws ArgumentError if any role's pool has insufficient supply for the number of groups the partition requires. active_runs empty is a no-op.
RunMaps.schedule_duplicates — Method
schedule_duplicates(runs::Vector{R}, n_duplicates::Int;
duplicate_id_factory::Function=default_duplicate_id_factory) where {R} -> RunMap{N}Build a brand-new RunMap linking every run in runs to n_duplicates freshly created duplicate nodes via link!(map, run, dup, :duplicate) (run is the edge's subject, the duplicate is its linked node).
Every run is registered even when n_duplicates == 0, so the run is still present for a downstream stage (e.g. schedule_controls!) to see.
Since this builds a fresh map, runs(map) immediately after calling gives exactly "originals + duplicates, nothing else yet" – the natural active_runs input for chaining into schedule_controls!.
Arguments
runs: flat list of run node ids to duplicate.n_duplicates: number of duplicate nodes to create per run. Must be>= 0.
Keyword Arguments
duplicate_id_factory:(run, replicate_index::Int) -> idcalled once per duplicate node to create; defaults todefault_duplicate_id_factory.
Node type resolution
The resulting RunMap{N} uses N = promote_type(eltype(runs), typeof(first generated id)), falling back to Any whenever the run type and the generated-id type don't coincide – mirrors schedule_uniform_controls's resolution rule. When runs is empty or n_duplicates == 0, no id is ever generated, so the result is typed RunMap{R} directly.
RunMaps.schedule_uniform_controls — Method
schedule_uniform_controls(runs::Vector{R}, role_counts, cap::Int;
solver::String="greedy",
linked_run_id_factory::Function=default_linked_run_id_factory,
kwargs...) where {R} -> RunMap{N}Build a brand-new RunMap implementing "uniform shared controls" scheduling: partition runs into groups capped at cap total nodes each (matching n_nodes/component_sizes), and within each group create sum(values(role_counts)) new control nodes (counts given per role, e.g. Dict(:positive=>1, :negative=>1)) linked from every run in that group (dense within the group).
Controls are never shared across groups, so each resulting group is automatically its own connected component – recover the grouping later via components/component_sizes; no extra metadata is stored on the map for this purpose.
Arguments
runs: flat list of run node ids to schedule. Duplicates are not deduplicated – pass aVectorwith the identity/multiplicity you intend.role_counts: number of controls to create per role, e.g.Dict(:positive=>1, :negative=>1). Keys may beSymbolorAbstractString(canonicalized toSymbol, matchinglink!); must be non-empty with strictly positive values.cap: maximum total node count (runs + controls) of any resulting group/component. Must exceedsum(values(role_counts)).
Keyword Arguments
solver: group-assignment algorithm,"greedy"(default) or"MILP". For the homogeneous/unweighted runs handled here both are exact and produce the same number of groups; MILP is provided as an extensible scaffold rather than a better answer – seeassign_groups_MILP.linked_run_id_factory:(group_index::Int, role::Symbol, replicate_index::Int) -> idcalled once per control node to create; defaults todefault_linked_run_id_factory.kwargs...: forwarded to the chosen solver (e.g.optimizer,timelimitfor"MILP").
Node type resolution
The resulting RunMap{N} uses N = promote_type(eltype(runs), typeof(first generated id)), falling back to Any whenever the run type and the generated-id type don't coincide (e.g. Int runs with the default Symbol-returning factory build RunMap{Any}) – a single node pool cannot hold both types under a narrower parameterization. When runs is empty, no id is ever generated, so the result is typed RunMap{R} directly.
See also: group_solvers, components.
RunMaps.set_metadata! — Method
set_metadata!(map::RunMap, run, linked_run, relation_type, data::AbstractDict) -> mapMerge data into the metadata of an existing edge. Throws ArgumentError if (run, linked_run, relation_type) is not an edge in map.
RunMaps.unlink! — Function
unlink!(map::RunMap, run, linked_run, relation_type=nothing) -> mapRemove the edge from run to linked_run labeled relation_type, along with its metadata. If relation_type is nothing (the default), removes all edges between run and linked_run regardless of type.
No-op if no matching edge exists. run and linked_run remain registered nodes even if this leaves them without any edges.