Skip to content

new vg combine, adding new options - #4965

Open
xinzilan wants to merge 3 commits into
masterfrom
new_vg_combine
Open

new vg combine, adding new options#4965
xinzilan wants to merge 3 commits into
masterfrom
new_vg_combine

Conversation

@xinzilan

@xinzilan xinzilan commented Jul 14, 2026

Copy link
Copy Markdown

Changelog Entry

To be copied to the draft changelog by merger:

  • vg combine gained options for stitching chunked assemblies and graphs: -f/--connect-fragments (now the default mode), -s/--shared-nodes, -m/--merge, and -u/--fusion.
  • vg concat is removed (use updated vg combine instead)

Description

This PR adds four new options to vg combine to support our chunked-assembly stitching workflow.

  • -f/--connect-fragments (default mode): Treats paths that share the same sample/locus/haplotype/phase block but differ only by subrange — e.g. CHM13#0#chr22 and CHM13#0#chr22[17475777] — as fragments of one path. It sorts the fragments by start offset, concatenates them in order, adds the missing inter-fragment edges, and rewrites the path name to a single [start-end] subrange covering the union. This runs by default whenever none of -c/-p/-m is given.

  • -s/--shared-nodes: Modifies the default (-f) mode so node IDs are not renumbered when merging. Node IDs appearing in both inputs must carry the same sequence; one copy is kept. This is intended for vg chunk output that shares boundary nodes across chunks, where the graph (rather than the assembly) was chunked. Fragments are merged into one path, trimming duplicated boundary steps. Valid only with the default mode; cannot be combined with -c/-p/-m.

  • -m/--merge: Overlap-aware stitching for chunked-assembly graphs that share REFERENCE-sense paths (e.g. CHM13#0#chr22). Inputs are bucketed by REFERENCE identity (sample/locus/haplotype), so chunks of different chromosomes (e.g. CHM13#0#chr21 and CHM13#0#chr22) can be passed together — each reference is stitched independently and kept as a separate path in the output. Within a reference, it sorts inputs by REFERENCE start offset, validates that each chunk boundary is a single chain node shared by every path (and equal to the REFERENCE end), trims each right chunk's leading node by the REFERENCE-offset overlap, connects the left end to the trimmed right start, then merges fragments like -f.

  • -u/--fusion (requires -m): Instead of adding an edge between the left end node and the right chunk's trimmed start node, fuses them into a single node carrying the concatenated sequence.

@faithokamoto

Copy link
Copy Markdown
Contributor

Are we ready to remove vg concat, then? #4932 (comment)

@adamnovak

adamnovak commented Jul 17, 2026

Copy link
Copy Markdown
Member

Yeah I think this will replace vg concat.

@adamnovak adamnovak left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found a few problems with this.

  1. There aren't any tests. There need to be tests for different edge cases of path handling (paths that overlap between input graphs, paths with gaps between them, paths that use phase blocks and don't really want to be connected, paths that are in pieces in their original input graphs) so that we can ensure that the code actually handles paths in the way we expect and produces the appropriate errors when graphs shouldn't be able to be combined.
  2. Because there aren't any tests, I think the code is able to do the wrong things with some of those situations. I think it's able to produce paths where their subranges don't actually reflect the total length of all their visits, for example. It's also claiming to be able to change the sense of paths from HAPLOTYPE to REFERENCE.
  3. The comments often substitute a summary of the code for an explanation of the abstraction that the code is supposed to provide to its caller to work with. This includes the user-facing documentation for the options.
  4. In general the different modes don't share code where it seems like they should, and don't explain why they can't. It looks like -f mode was implemented to combine graphs and join up paths, and then -m mode was implemented to combine fragments along a reference-sense backbone and join up paths, and besides the path-joining function they share basically nothing, with similar problems solved by main force with completely different code. I would expect to see one main implementation of loading and combining the graphs, which is able to do different things at different points by e.g. checking a condition and deciding whether or not to enforce constraints around a single REFERENCE set of paths. I'm not sure the reference mode can't just be a pass, rather than its own nearly-complete implementation.
  5. We're approaching the level of complexity where we no longer want all the code to live in the subcommand file. There probably should be a widget class in src or some new algorithms in src/algorithms to hold the guts of this.

static int connect_fragments_combine(int argc, char** argv, const Logger& logger,
bool shared_nodes);
static int merge_combine(int argc, char** argv, const Logger& logger, bool fusion);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just passing argc and argv to all of these (and implicitly optind) was probably a bad idea for cat_proto_graphs(), and it gets worse and worse the more we use it.

I think we did it so we could use the argc/argv version of get_input_file(), but since we also have versions of that function and its friends that work OK with string filenames and "-" for stdin, what we should really do is something like:

std::vector<std::string> filenames_to_combine;
while (optind < argc) {
        filenames_to_combine.push_back(get_input_file_name(optind, argc, argv));
}

And then we would pass a vector of filenames to all the combine functions and they can use:

for (const std::string& filename : filenames_to_combine) {
    get_input_file(filename, [&](std::istream& stream) {
        // Use the file
    });
}

Or something vaguely similar with numbered indexing if they need to handle the first file specially.

Comment on lines +241 to +248
// Helper: compute the total sequence length spanned by a path in `graph`.
static size_t compute_path_length(const PathHandleGraph* graph, const path_handle_t& path) {
size_t length = 0;
for (handle_t h : graph->scan_path(path)) {
length += graph->get_length(h);
}
return length;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably check if the loaded graph happens to implement PathPositionHandleGraph and if so use get_path_length().

PathPositionHandleGraph* pos_graph = dynamic_cast<PathPositionHandleGraph*>(graph);
if (pos_graph != nullptr) {
    return pos_graph->get_path_length(path);
}

(We also might consider eventually promoting that method up to PathHandleGraph since I think some graphs track that for you but don't do full efficient position lookups.)

Comment on lines +204 to +212
// --connect-paths / -p mode.
//
// Loads the first graph as the destination accumulator, then for each
// subsequent graph renumbers its node IDs out of the way and appends it,
// connecting same-named paths end-to-end across graphs (an edge is added from
// the last node of a path in the accumulator to the first node of the same-named
// path in the incoming graph). See handlealgs::append_path_handle_graph.
int connect_paths_combine(int argc, char** argv, const Logger& logger) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a summary where an explanation is needed. Some of the other, new functions also seem to have this problem.

The right place to set out how a function works, with an overview of the steps it is going to take, is inside the function body, because how the function does what it does is its own business. The point of a function is to use abstraction to keep all that business inside it as much as possible, so that whoever is using the function doesn't need to take up any of their 7 +/- 2 working memory slots thinking about it, and can thus keep larger problems in their head than they otherwise could.

I usually do that interleaved with the code for each step:

// Load the first graph as the destination accumulator
...
for (...) {
    // For each subsequent graph
    ...
    // Renumber its node IDs out of the way
   ...
}
...

That way it's easy to make sure the summary changed when the code changes. But you could also put an outline of the plan at the top of the function.

If you're putting a comment right above a function to introduce it, that comment should explain the why of the function, rather than the how. (It also ought to be a doc comment with /// or /** ... */ delimiters, so documentation tools will pick it up.) It should say things like under what circumstances one may or may not call this function, and what the function is intended to accomplish, and what the caller can rely on about the result. Something like:

/**
 * Combine graphs while connecting paths.
 *
 * Takes argc and argv, and expects flags to have already been parsed,
 * so optind must be pointing to the filename of the first graph on the command line.
 *
 * Everything will end up in a single graph, which comes out via standard output.
 * Graph nodes will end up renumbered so node IDs do not collide.
 * Paths with identical names will end up concatenated together, with the required edges in the graph.
 * Returns 0 on success.
 */

If instead you put a summary of the function's code, anyone who might need to use the function is left to try to read the summary, figure out why the particular things it describes being done are being done and what their consequences will be, and then figure out if those consequences will guarantee that the things they need are going to be true when the function returns. Also, they could just read the code, which has a good chance of being clearer about what the function does in confusing circumstances than the summary can be.

AI code generators in particular love to substitute summaries for explanations, because they're fundamentally translation systems at heart and find it simple to translate code to English and challenging to use a theory of mind to produce text about why the code is there in the first place. If you want to try and use one for this, you might have to wrestle with it or adjust the project's BOTS.md to encourage it.

size_t phase_block;
size_t length;
};
using GroupKey = std::tuple<PathSense, string, string, size_t, bool>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be a comment explaining what the 5 fields mean here.

Comment on lines +295 to +303
auto fragment_start = [](const Fragment& f) -> offset_t {
if (f.subrange != PathMetadata::NO_SUBRANGE) {
return f.subrange.first;
}
if (f.phase_block != PathMetadata::NO_PHASE_BLOCK) {
return (offset_t)f.phase_block;
}
return 0;
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this second-guessing the distinction that the source graph is making between subrange and phase block? If the source graph has genuine unpositioned phase blocks 1, 2, 3, etc., won't we be mistaking those for start offsets here?

Comment on lines +659 to +676
// into one accumulator graph. This is steps (2)-(4) of --merge/-m:
//
// (2) Sort inputs by REFERENCE start offset.
// (3) Validate the chain boundary on every input:
// - every non-first input's paths all start at its REF start node
// - every non-last input's paths all end at its REF end node
// Boundary nodes must be visited in forward orientation.
// (4) Walk pairs left->right. For each right chunk:
// - overlap = dest_REF_end_offset - src_REF_start_offset
// (error on negative gap or if overlap >= start node length)
// - if overlap > 0: divide_handle(start, overlap), pop_front_step on
// every path in the right chunk, destroy the head piece. The new
// start node is the tail piece.
// - renumber the right chunk's IDs out of dest's range, copy
// nodes/edges/paths into dest under their per-fragment names.
// - connect: if --fusion, glue the left end node and the new right
// start node into a single node with concatenated sequence;
// otherwise just create_edge(left_end, new_right_start).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This again looks like summary rather than explanation.

Comment on lines +58 to +59
<< " kept (intended for `vg chunk` output that shares" << endl
<< " boundary nodes across chunks). Fragments are merged" << endl

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is about what the flag is meant for it shouldn't be a parenthetical in the middle attached to a sentence about something else.

Comment on lines +71 to +78
<< " Within a reference, sorts inputs by REFERENCE" << endl
<< " start offset, validates that each chunk's" << endl
<< " boundary is a single chain node shared by" << endl
<< " every path (and equal to the REFERENCE end)," << endl
<< " trims each right chunk's leading node by the" << endl
<< " REFERENCE-offset overlap, connects the left" << endl
<< " end to the trimmed right start, then merges" << endl
<< " fragments like -f." << endl

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too much like a summary of the code. Instead, we want to talk here about the important differences between the modes from the point of view of someone who has graph pieces to put together and needs to figure out which mode is right for them. They are going to assume that nodes get trimmed by the correct amount to accomplish the task; they need to know what task each of the flags represents.

size_t src_start_len = src->get_length(in.ref_start_handle);

// Length of the left chunk's boundary node (only matters for fusion:
// under -u every right-chunk path's first node grows by this many bp,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better if the logic described things in terms of the function's own arguments, and not the CLI flags which ought to live on a completely different layer of the stack of abstractions.

Comment on lines +818 to +827
struct PathSnapshot {
PathSense sense;
string sample;
string locus;
size_t haplotype;
size_t phase_block;
subrange_t subrange;
bool is_circular;
std::vector<handle_t> steps;
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't we define another class earlier that holds all the path metadata? Maybe there should be a shared struct-of-path-metadata?

@faithokamoto

Copy link
Copy Markdown
Contributor

I've taken care of removing vg concat, so that now the two changes will merge at the same time. Shouldn't affect any of the vg combine updates.

@faithokamoto

Copy link
Copy Markdown
Contributor

I did a quick check through the old issues, and this may be relevant to #2688?

@adamnovak

Copy link
Copy Markdown
Member

Yeah, this should fix #2688.

@faithokamoto

Copy link
Copy Markdown
Contributor

Ah, this line is going to need to be replaced by vg combine. I defer the decision of how to do that to the people who understand what is happening here.

vg concat x.vg y.vg z.vg >q.vg

@xinzilan
xinzilan requested review from adamnovak and removed request for adamnovak July 18, 2026 01:32
@faithokamoto

Copy link
Copy Markdown
Contributor

#2677 may also be relevant, if this PR means we could do the workflow Adam said was impossible here: #2677 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make vg combine handle subpaths and link chunked graphs (for chunked augmentation)

3 participants