Scores

A genomic score attaches values to places in the genome. GAIn has three kinds, and which one a resource is decides what “a place” means:

Kind

A record is

Typical resource

PositionScore

one position

conservation tracks such as phastCons100way

AlleleScore

one substitution or allele at a position

variant-level scores such as CADD

FragmentScore

an interval carrying attributes

CNV collections, ATAC fragments, region annotations

All three are built by the same function, which reads the resource’s declared type and returns the matching class:

from gain.genomic_resources.repository_factory import build_genomic_resource_repository
from gain.genomic_resources.genomic_scores import build_score_from_resource_id

grr = build_genomic_resource_repository()
score = build_score_from_resource_id("hg38/scores/phastCons100way", grr)

They share the base class GenomicScore, which owns everything that does not depend on what a record means: the lifecycle, the score definitions, the filter compiler, and the raw record and column-array reads.

The lifecycle

A score holds an open table — a tabix file, a bigWig, or a VCF — so it is built closed. open() opens it and returns it, so the call chains, and is_open() reports the state.

Entering the context manager does not open the score. __enter__ returns the score unchanged; what the with block gives you is the guaranteed close() on the way out. So call open() explicitly and let with manage the closing:

with build_score_from_resource_id("hg38/scores/phastCons100way", grr).open() as score:
    print(score.get_scores_at_position("chr21", 5_030_000))

Omitting the .open() raises ValueError: genomic score <id> is not open on the first read.

This is stated once here and holds for all three kinds. Reading from a closed score is an error, not an implicit open — a score that opened itself on first use would make the cost of the first read unpredictable, and would hide a missing close in long-running code.

Reading positions

PositionScore is the kind whose reads are per-base. get_scores_at_position() returns one tuple for one position; get_scores_in_region() yields one tuple per position of an interval. Both return None in the slot of a score that has no value there, which is how “uncovered” is distinguished from a genuine zero:

for values in score.get_scores_in_region("chr21", 5_030_000, 5_030_010):
    print(values)

Where you want an interval reduced to a single number rather than a value per base, use the aggregating forms — get_score_in_region_agg() for one score, or get_scores_in_region_agg() for several queries at once. Each score’s default aggregator comes from its configuration; DEFAULT_AGGREGATORS on each class gives the fallback per value type when the configuration names none. get_scores_in_bins() is the same reduction applied to a grid of fixed-width bins, which is what a genome-browser-style plot wants.

Reading alleles and fragments

AlleleScore adds reads that take a reference and an alternative as well as a position — fetch_allele_scores() is the direct one. An allele score is in one of two modes, substitutions or alleles, reported by substitutions_mode() and alleles_mode(); the mode decides whether an indel can match at all.

FragmentScore reads intervals. Its method names say exactly which relation to the query region they use — overlapping_region for fragments that intersect it, starting_in_region for fragments that begin inside it, at_position for those covering one point. The distinction matters: summing a score over overlapping fragments double-counts a fragment that straddles two adjacent query windows, and the starting_in form is the one that tiles.

Both kinds have an aggregating read that answers off a single walk of the region and returns a small record rather than a bare tuple — AlleleAggregate and FragmentAggregate. Each carries the reduced values alongside what the walk saw (the matched allele keys, or the fragment count), because the two halves are only guaranteed to agree when they come off the same walk.

In every case values is parallel to the queries that were asked, not keyed by score id — one score asked twice with two aggregators is two queries and therefore two values.

Score definitions

A score resource declares its columns in a scores: block, documented as YAML on Position scores and the sibling sections for allele and fragment scores. At runtime each entry of that block becomes one GenomicScoreDef — the object that knows a score’s id, its value type, how to turn a raw cell into a value, and which histogram configuration belongs to it.

That is the boundary: the keys are described on Genomic resources and repositories, the object here.

for score_def in score.score_definitions.values():
    print(score_def.score_id, score_def.value_type)

one = score.get_score_definition("phastCons100way")

parse_value() and parse_array() are the scalar and vectorised forms of the same conversion; the array form is what the column-array reads use, and it is the reason a large region can be read without building one Python object per row.

Filtering

compile_filter() turns a boolean expression over a score’s own columns into a ScoreFilter, which the reads accept as a score_filter argument and apply while walking:

keep = score.compile_filter("phastCons100way > 0.9")
for record in score.fetch_records("chr21", 5_030_000, 5_040_000, score_filter=keep):
    print(record)

A filter is bound to the score that compiled it and refuses to run against another — expressions name columns, and the same column name on a different resource is a different column.

Bulk reads

fetch_records() yields one record per row and is the general form. fetch_region_value_arrays() is the fast path: it returns whole columns as arrays instead of a record per row, for the scores and backends that support it. Ask supports_region_value_arrays() first — it answers for a specific list of scores, because support depends on the value types requested and not only on the backend.

API

gain.genomic_resources.genomic_scores.build_score_from_resource_id(resource_id: str, grr: GenomicResourceRepo | None = None) GenomicScore[source]
class gain.genomic_resources.genomic_scores.GenomicScore(resource: GenomicResource)[source]

Base class for genomic score resources.

GenomicScore provides a unified interface for accessing and managing genomic annotation scores stored in various formats. It serves as the foundation for specialized score types including PositionScore (position- based scores) and AlleleScore (variant-specific scores).

This abstract base class handles: - Resource configuration validation and normalization - Score definition management and parsing - File format abstraction through GenomicPositionTable - Histogram and statistics management - Default annotation attribute configuration - Context manager protocol for resource lifecycle

Score resources can be stored in multiple formats: - Tabix-indexed files (TSV, BED) - VCF files (particularly for allele scores) - BigWig files (for position scores) - In-memory tables (for testing)

Configuration Structure:

A genomic score resource requires a YAML configuration file (genomic_resource.yaml) specifying:

  • type: Resource type (position_score, allele_score)

  • table: Table configuration with filename, format, and column mappings for chrom, pos_begin, pos_end (and ref/alt for allele scores)

  • scores: List of score definitions with id, type, name/index, description, and optional aggregators

  • default_annotation: Optional list specifying which scores to include in default annotations with optional name mappings

  • histograms: Optional histogram configurations for statistics

Score Definition:

Each score in the resource is defined with:

  • id: Unique identifier for the score

  • type: Data type (int, float, str, bool)

  • name/index: Column name or index in the data file

  • desc: Human-readable description

  • na_values: Values to treat as missing/NA (optional)

  • hist_conf: Histogram configuration for statistics (optional)

  • aggregator: Default aggregator (optional). How several values for one annotatable are reduced to one; the default depends on the resource type and the score’s value type.

Usage Pattern:

Genomic scores follow a resource lifecycle pattern:

  1. Build/retrieve the resource from a repository

  2. Create a score object from the resource

  3. Open the score to initialize data access

  4. Query scores using fetch methods

  5. Close the score to release resources

Example using context manager:
>>> from gain.genomic_resources.genomic_scores import (
...     build_score_from_resource_id
... )
>>> score = build_score_from_resource_id("phastCons100way")
>>> with score.open():
...     # Score is open and ready to use
...     chromosomes = score.get_all_chromosomes()
...     scores = score.get_all_scores()
...     # Query data...
>>> # Score is automatically closed
Statistics and Histograms:

GenomicScore supports automatic statistics generation including:

  • Value distribution histograms

  • Min/max ranges for numeric scores

  • Category frequencies for categorical scores

  • Custom histogram configurations per score

Variables:
  • resource (GenomicResource) – The underlying genomic resource object

  • resource_id (str) – Unique identifier for the resource

  • config (dict) – Validated and normalized configuration dictionary

  • table (GenomicPositionTable) – Data access abstraction layer

  • score_definitions (dict[str, GenomicScoreDef]) – Mapping of score IDs to their internal definitions including parsers and metadata

  • table_loaded (bool) – Flag indicating if the table is currently open

Key Methods:

open(): Initialize the score resource for data access close(): Release resources and close the data table get_all_scores(): Get list of all available score IDs get_all_chromosomes(): Get list of all available chromosomes has_chromosome(): Whether one chromosome is available get_score_definition(): Get metadata for a specific score get_default_annotation_attributes(): Get default annotation config get_histogram(): Load histogram for a score (if available) get_score_range(): Get value range for a numeric scores

Per-kind Methods:

A kind whose records read as something other than the span they cover states that ONCE, by overriding:

  • _score_segments(): what a region’s raw records mean for this kind. region_values_from_records is the request resolution followed by it, fetch_region_segments_scores is THAT applied to fetch_records, and the statistics scan is it applied to validate_records(score, fetch_records(...)) – so a kind states its reading once and every consumer gets it (ADR 0008). Override this and not region_values_from_records: the resolving entry is shared by every kind, and a read holding an already-resolved request composes this body without going through it (gain#1282).

  • record_weight(): how many times one record’s value counts when a region is aggregated. Every reader goes through it – the annotators’ aggregate_region, the per-record scan, and the bulk scan via record_weights (gain#1095).

  • _aggregation_segments(): whether those records are cut down to the queried window before they are weighed. Unlike the others this HAS a default – not clipping – because it is a consequence of the weight rule rather than a rule of its own: a kind that counts a record once counts it wherever it falls.

All but the last have no default. A kind that inherited one would be weighed by a rule nobody chose for it, which is the failure ADR 0008 exists to undo.

A kind’s two validation rules are not on this list, and not on this class: they are registered per kind in gain.genomic_resources.statistics.record_validation, whose default refuses a kind nobody wrote one for (ADR 0027).

See also

  • PositionScore: For position-based genomic scores

  • AlleleScore: For variant-specific genomic scores

  • GenomicResource: Base resource abstraction

  • GenomicPositionTable: Table format abstraction

aggregate_region(chrom: str, pos_begin: int | None = None, pos_end: int | None = None, scores: list[str | tuple[str, str]] | None = None) list[str | int | float | bool | None][source]

Reduce a region to one value per requested score.

The aggregating counterpart of fetch_region_segments_scores(), which it is built on: that method yields one entry per record, this one folds those entries into a single value per request.

Each request is either a score id – aggregated with the resource’s own default, which score_def.finish_scoredefs resolved from this class’s DEFAULT_AGGREGATORS – or a (score_id, aggregator) pair naming one explicitly. The aggregator string is whatever the config accepts, parametrized forms (join(,)) included.

Returns a list parallel to ``scores``, not a dict. One score may legitimately be requested twice with different aggregators – ["s", ("s", "max")], which is what an annotation config does when it exposes one source as both a min and a max attribute – and a dict keyed by score id would silently drop one of them.

An empty region is not an error: each aggregator answers for itself. list returns []; max returns None; and so does count, which chooses to report nothing rather than 0 for an empty region (see CountAggregator.get_final). This method does not second-guess any of them. That is deliberately unlike the per-position reads (get_scores_at_position, fetch_allele_scores), which answer None where there is no data – aggregating nothing is a well-defined question, reading a value where there is none is not.

Values reach the aggregator exactly as the record carried them, None included, because that is what the annotators do (each aggregator decides what a null means for it) and the point of this method is to give the answer they would.

property chrom_length_source: ChromLengthSource

What the score’s own file can say a contig’s length is.

Declared on the backend class, so answered on a closed score – which is what a caller weighing whether to open the table for its lengths at all (coverage’s second rung, gain#1448) needs. The lengths themselves come from derive_chrom_lengths().

close() None[source]

Close the underlying table and mark the score not open.

open() may be called again afterwards.

compile_filter(expression: str) ScoreFilter[source]

Compile a boolean expression into a filter over this score.

The expression names this resource’s own scores and relates them with >, >=, <, <=, ==, != and in, combined with not, and and or; the result is passed back to any of the record reads as score_filter.

Raises ScoreFilterError on an expression that does not parse or that names a score this resource does not define. See compile_score_filter() for what compiling settles, docs/adr/0017-score-filtering-is-a-score-capability.md for why the capability sits on the score, and docs/adr/0018-score-filter-grammar-extension.md for the language’s precedence and what a name may contain.

fetch_records(chrom: str, pos_begin: int | None, pos_end: int | None, *, score_filter: ScoreFilter | None = None) Generator[tuple[Any, ...], None, None][source]

Yield the records of a region, optionally filtered.

A caller reads a record’s positional fields from its slots (record[CHROM], record[POS_BEGIN], …) and a score value through get_score_value_from_record() or get_score_values_from_record() on this score.

score_filter is a predicate from compile_filter(), applied to each record; only records it accepts are yielded. None – the default – yields what the table yields, and is the whole of what this method did before filtering became a score capability.

chrom is required, here and throughout the region-read family. A caller that wants every record of a table asks the table: score.table.get_all_records().

Nothing here is checked before the first next(), the filter’s ownership included: every backend’s get_records_in_region is itself a generator function, so an unknown contig has always been reported from the first record read rather than from the call, and there is no eagerness left to preserve by structuring this any other way. That is a property of this read rather than a rule for the family: a read that materialises has no generator body to defer a refusal into, and AlleleScore.fetch_allele_records() accordingly refuses from the call.

fetch_region_segments_scores(chrom: str, pos_begin: int | None = None, pos_end: int | None = None, scores: Sequence[str] | None = None, *, score_filter: ScoreFilter | None = None) Generator[tuple[int, int, list[str | int | float | bool | None]], None, None][source]

Yield (begin, end, values) per record touching the region.

One tuple per underlying RECORD – a segment, at that record’s own extent – not one value per position. The region’s records, read as this kind means them. A record straddling the region’s edge is reported whole: what a partial overlap means depends on what the caller is computing, so a caller answering a question about the window composes clip_to_region() over this stream, or calls clip_span() per segment (ADR 0008).

score_filter – from compile_filter() – travels to fetch_records() and nowhere else: the records it rejects never reach the transform, so a rejected record costs no value extraction. It reads the RECORD, so it may name any score the resource defines, including one outside scores. It is not a second kind of read and it selects nothing the record read would not: the composition below is the same one either way, with a filtered stream in place of an unfiltered one.

Its ownership check is the one refusal here that does NOT land on the call. It rides fetch_records(), whose generator body defers it and which says so, where the request checks region_values_from_records() runs are eager – so a filter compiled against a different score is refused on the first next(), not from the call that a closed score, an unknown contig and an unknown score id are refused from.

A plain read: it checks nothing. The statistics scan reads the same records through the same transform with validate_records() composed in front, and that extra link – visible at the consumer, in genomic_scores_impl/scan.py – is the whole of the difference between the two (ADR 0008).

One body per kind, in _score_segments(), rather than one per kind per consumer: two that had to agree is how the paths drift. The fragment plane reads through this method for that reason (gain#1272), and the position plane reaches the same body with an already-resolved request (gain#1282) – different entries, one reading.

fetch_region_value_arrays(chrom: str, pos_begin: int | None, pos_end: int | None, scores: list[str], *, batch_size: int = 100000) Generator[tuple[ndarray, ndarray, dict[str, ndarray]], None, None][source]

Fetch a region as column arrays, without building a record per row.

The bulk counterpart of fetch_records(), for a caller that scans a whole region and wants columns rather than rows – statistics, above all. Each batch is (pos_begin, pos_end, {score_id: values}): the one-based position arrays, plus one array of parsed values per requested score.

Values are parsed, by the same contract the per-record read uses. Each column goes through GenomicScoreDef.parse_array(), whose agreement with the per-value GenomicScoreDef.parse_value() is pinned by test_parse_array_agrees_with_parse_value_fuzz. So NA sentinels and unparseable cells arrive as that score’s non-value, whatever the backend stores underneath: a float or int score yields float64 with nan for no value, a str score an object array with None. The array’s dtype follows the score’s declared type, not the backend’s – a caller reading several scores in one batch can be handed both shapes.

That parse is why a value type the definition cannot parse as a column is refused, and why supports_region_value_arrays() asks about the scores and not only about the backend.

It does NOT clip. A record overlapping the region’s start is yielded whole, exactly as fetch_records() yields it; trimming to [pos_begin, pos_end] is the caller’s, because what a partial overlap means depends on what the caller is computing.

batch_size is a HINT. A backend whose read granularity is fixed by its own windowing – BigWigTable, whose batches are sized by its adaptive fetch window – ignores it.

Each score id gets an array of its own – the parse builds one per id, so two ids sharing a payload column do not alias.

The guards below run when this method is CALLED, not on the first next() – which is why the streaming half lives in _value_array_batches rather than a yield here.

get_all_chromosomes() list[str][source]

The chromosome names the score’s table holds, in table order.

Raises ValueError on a score that is not open.

get_config() dict[str, Any][source]

The configuration, validated and normalized at construction.

get_default_annotation_attribute(score_id: str) str | None[source]

Return default annotation attribute for a score.

Returns None if the score is not included in the default annotation. Returns the name of the attribute if present or the score if not.

get_default_annotation_attributes() list[Any][source]

Collect default annotation attributes.

static get_schema() dict[str, Any][source]

The config this kind accepts; each kind extends it.

get_score_value_from_record(record: tuple[Any, ...], score_id: str) str | int | float | bool | None[source]

Read one configured score off a record of this score’s table.

get_score_values_from_record(record: tuple[Any, ...], score_defs: list[GenomicScoreDef]) list[str | int | float | bool | None][source]

Read several scores off one record, for ALREADY-resolved defs.

The bulk counterpart of get_score_value_from_record(): a caller resolves score names to definitions once per fetch and passes them per record, so the name->definition lookup stays out of the per-record loop.

has_chromosome(chrom: str) bool[source]

Answer whether this score’s table carries chrom.

The yes/no half of get_all_chromosomes(), and what every contig screen in the read path asks – the annotators’ pre-read screens, the shared region-read refusal, the position kind’s absent-contig branch and both allele reads’ refusals. All of them spelled chrom not in self.get_all_chromosomes(), a walk of the ordered list whose cost grew with the resource’s contig count and with the contig’s index in it, and grew WORST for a contig the resource does not carry, since that walks the whole list before it can say no (gain#1304).

Raises ValueError on a score that is not open, exactly as get_all_chromosomes() does and for the same reason: those screens got that refusal for free from the accessor they used, and a predicate that answered False instead would turn “this score was never opened” into “this score does not carry that contig” at every one of them.

The ordered list keeps its meaning, its order and its aliasing for the callers that genuinely need the collection – a statistics scan splitting a genome into region tasks, the resource implementation’s contig report. This is for the ones that only ever asked a question.

is_open() bool[source]

Whether open() has run and close() has not since.

open() Self[source]

Open genomic score resource and returns it.

Validate and route BEFORE opening, and so before publishing. Every input to both steps is known at construction – the table’s class, its yields_records ClassVar, and the score definitions – so neither needs the open handle, and two things fall out of that order:

  • a refusal costs no handle. Routing after table.open() would leave a caller that is not using the with form holding an opened pysam handle it cannot reach: table_loaded would still be False, so close() would not have been reached. Raising first means there is nothing to leak. The bigWig config validation sits here for exactly that reason.

  • table_loaded = True is what makes this score look open to everyone else: from that write on, another caller’s open() takes the is_open() early return above and reads _extract_value straight away. Routed last, that caller could catch the score published-but-unrouted, and since the routing has no default at all, that caller reads an AttributeError. Scores are shared across threads (the process-wide in-memory fragment-score cache; gain-web-api’s thread pool), so the window is reachable; this ordering keeps the ROUTING out of it. Pinned by test_the_score_is_routed_before_it_reports_itself_open.

It does not make open() as a whole safe to race, and does not claim to: resolve_score_indices still runs after the score has published itself open, so a caller that catches that window reads a score def with no score_index yet. That window is older than this ordering and untouched by it – open() is not synchronised, and making it so is a separate change.

abstractmethod classmethod record_weight(left: int, right: int) int[source]

How many times one record’s value counts when aggregating.

The rule is a property of the resource TYPE: a position-score record counts once per base pair of the queried region it covers, an allele line counts once, a fragment counts once however long it is. One record, one count, is the answer for everything except a position score.

The kind’s single statement of that rule, and every reader that weighs a RECORD goes through it: aggregate_region() folds with it, the per-record statistics scan calls it, and the bulk scan broadcasts it over a whole batch through record_weights(). One statement, so a kind cannot weigh its records one way in one of those and another way in the next. Pinned by test_the_weight_rule_is_stated_once_per_kind.

A position score’s logical plane is deliberately NOT among them. It tiles POSITIONS rather than folding records – get_scores_in_region_agg weighs each run by its length – and since gain#1131 that is the path annotation takes. The two agree for this kind, a clipped record’s width being exactly the length of the run it covers, but they agree by arithmetic rather than by reading one statement, so a change here is not automatically a change there.

An implementation must be numpy-elementwise – an arithmetic expression over left and right, or a constant. It is declared over scalars because that is what its per-record callers hand it, but record_weights() answers a whole batch by handing it the position COLUMNS instead, and only an elementwise body gives the same answers that way.

Most ways of breaking that break loudly – a body branching on left raises ValueError on an array’s ambiguous truth, one calling int() a TypeError. The contract is written down for the ones that do not: a body REDUCING its arguments (int(np.mean(right - left + 1))) hands back a plain number, which is then broadcast as though it were every record’s weight, and the two scan paths disagree with nothing raised. That is what test_the_weight_rule_is_stated_once_per_kind’s broadcast-agreement assertion is there to catch.

Deriving a weight from the span unconditionally is what this hook exists to prevent: it would give a fragment its length as a weight and disagree with the fragment score annotator for every fragment longer than one base pair.

classmethod record_weights(begins: ndarray, ends: ndarray) ndarray[source]

record_weight() over a whole batch’s position columns.

The bulk statistics scan has no record to hand the scalar hook, so it weighs a batch here instead. This does not restate the rule – it broadcasts the ONE statement of it, which is why the scan may not read the weight anywhere else.

The widening is the elementwise contract being spent: the hook is declared over scalars and its bodies are arithmetic, so the same expression answers a column. A kind whose weight is a CONSTANT answers with that constant however it was called, so a 0-d result is filled out to the batch’s shape rather than treated as an error.

region_values_from_records(records: Iterator[tuple[Any, ...]], chrom: str, pos_begin: int | None = None, pos_end: int | None = None, scores: Sequence[str] | None = None) Generator[tuple[int, int, list[str | int | float | bool | None]], None, None][source]

Extract this kind’s (begin, end, values) from raw records.

The region read expressed as a function OF a record stream, which is what lets the two consumers of a region differ by what they COMPOSE rather than by a flag: fetch_region_segments_scores() is this applied to fetch_records(), and the statistics scan is this applied to validate_records(score, fetch_records(...)). Neither can quietly acquire the other’s behaviour, and no argument travels down to say which of the two is reading (ADR 0008).

chrom, pos_begin and pos_end name the region the records were asked for. Nothing is fetched here, and nothing is reshaped to the window either – what a partial overlap means belongs to the caller (ADR 0008); a consumer answering a question about the window clips with clip_span(). The positions are what the guards below are about.

The guards run when this is CALLED rather than on the first next() – the pattern fetch_records() documents – which is why the streaming half lives in _score_segments. They stay here rather than moving down into fetch_records because that is not where the request is: half of what they resolve is the score ids, which fetch_records is not even given, and the contig half would be a THIRD screen of the same contig in one read – after this one and before the backend’s own, which refuses an unknown contig from get_records_in_region regardless. This used to be argued on cost, from a time when each screen walked the ordered contig list; since gain#1304 the screen is a set lookup and the argument is only about where a request is resolved, which is here.

What a kind yields is _score_segments(), and not this method: the resolution above is the same for every kind, the reading below is not, and since gain#1282 they are split so that a kind states only the half that is its own. Override THAT to say what a record means here; overriding this one would take the resolution with it, and would be skipped by a read that enters below it holding definitions it has already resolved.

resource_files() set[str][source]

The resource’s files the score reads: the data file, plus the index on a backend that reads one.

Answered by the table, which knows how it opens, on a closed score – the file set has to be known before any of the files is.

supports_region_value_arrays(scores: list[str]) bool[source]

Whether fetch_region_value_arrays() will serve these scores.

Answers the two things a caller can be wrong about: the backend serves the bulk column-array read, AND every named score is one this facade can parse. A predicate that answered only the first would say True for a call that then refuses – not a capability query but a trap.

It is not a promise the call cannot fail for some OTHER reason. A score whose configured column index does not exist in its backend’s payload still raises (deliberately – see BigWigTable), and so does a closed score or an unknown contig. This answers “is this score the kind this method serves”, not “is every argument valid”.

The value-type half is not a consumer’s condition leaking in: the facade parses, so it serves the value types GenomicScoreDef.parse_array() defines a column parse for (BULK_PARSEABLE_VALUE_TYPES) and no others. What a consumer additionally needs stays with the consumer: the statistics scan also requires a bounded region, and each of its two entry points asks for its own histogram or min/max pairing (see genomic_scores_impl.scan.can_bulk_histogram and can_bulk_min_max). The scan does not re-test the resource KIND; ADR 0001 records why. What it does NOT require is a particular record shape: the accumulator reads the kind’s own record_weight and the scan’s door reads the rule registered for the kind in gain.genomic_resources.statistics.record_validation, so a position, allele and fragment score are all served.

Answerable on an UNOPENED score: the table and the score definitions are both built in __init__, so nothing here touches the file.

class gain.genomic_resources.genomic_scores.PositionScore(resource: GenomicResource)[source]

Position-based genomic score resource.

A PositionScore provides scores associated with genomic positions, where each score value applies to a specific genomic coordinate or range. Unlike AlleleScore, PositionScore does not consider reference or alternative alleles - scores are purely position-based.

Typical use cases include: - Conservation scores (e.g., phastCons, phyloP) - Mappability scores - GC content - Recombination rates - Any metric that depends only on genomic position

The score data can be stored in various formats including tabix-indexed files, BigWig files, or in-memory tables.

Example

>>> from gain.genomic_resources.repository_factory import (
...     build_genomic_resource_repository
... )
>>> repo = build_genomic_resource_repository()
>>> resource = repo.get_resource("phastCons100way")
>>> score = build_score_from_resource(resource)
>>> with score.open() as score:
...     # Fetch scores at a specific position
...     values = score.get_scores_at_position("chr1", 12345)
...     # Fetch scores across a region
...     region = score.fetch_region_segments_scores(
...         "chr1", 10000, 20000)
...     for pos_begin, pos_end, scores in region:
...         print(f"{pos_begin}-{pos_end}: {scores}")

Aggregating those values over the region is the resource’s job since gain#1131: get_scores_in_region_agg reduces a region to one value per query, and gain.annotation.position_score_annotator asks for that rather than folding records of its own. What the kind contributes to the reduction is record_weight – how many queried bases a record covers, and so how many times its value counts.

Variables:
  • resource (gain.genomic_resources.repository.GenomicResource) – The underlying GenomicResource object

  • resource_id – Unique identifier for the resource

  • config (dict) – Configuration dictionary for the score

  • table – GenomicPositionTable for data access

  • score_definitions (dict[str, ScoreDefT]) – Dictionary mapping score IDs to their definitions

Key Methods:

get_scores_at_position: Get score values at a specific position fetch_region_segments_scores: Iterate over score segments in a genomic region, each at its record’s own extent get_scores_in_region_agg: Reduce a genomic region to one value per aggregation query, weighing each record by the bases it covers

static get_schema() dict[str, Any][source]

The GenomicScore schema plus a per-score aggregator.

get_score_at_position(chrom: str, pos: int, score: str | None = None) str | int | float | bool | None[source]

Return one score’s value at one position, None if uncovered.

The singular form of get_scores_at_position(); score of None is honoured only when the resource declares exactly one.

get_score_in_bins(chrom: str, start: int, end: int, bin_size: int, score: str | None = None, aggregator: str | None = None, none_value_replacement: str | int | float | bool | None = None) Generator[tuple[int, int, str | int | float | bool | None], None, None][source]

Yield (bin_start, bin_end, value) per bin of [start, end].

The singular form of get_scores_in_bins(); score of None is honoured only when the resource declares exactly one.

get_score_in_region(chrom: str, start: int, end: int, score: str | None = None) Generator[str | int | float | bool | None, None, None][source]

Yield one value per position of [start, end] for one score.

The singular form of get_scores_in_region(); score of None is honoured only when the resource declares exactly one.

get_score_in_region_agg(chrom: str, start: int, end: int, score: str | None = None, aggregator: str | None = None, none_value_replacement: str | int | float | bool | None = None) str | int | float | bool | None[source]

Reduce [start, end] to one value for one score.

The singular form of get_scores_in_region_agg(); score of None is honoured only when the resource declares exactly one.

get_scores_at_position(chrom: str, pos: int, scores: Sequence[str] | None = None) tuple[str | int | float | bool | None, ...][source]

Return the score values at one position, None where uncovered.

Read straight off _position_runs() rather than through the region read’s per-position expansion: a one-position region is exactly ONE run – no run is ever yielded empty, and the run lengths sum to the region width – so there is nothing to expand and nothing to index a position out of.

The run is DRAINED rather than abandoned; that is simpler to write, not load-bearing, since gain#1120 moved the tabix buffer prune into a finally (fragment.py cross-references this paragraph, and test_a_walk_of_point_reads_leaves_the_tabix_ buffer_pruned pins the drained half).

Going straight to the runs rather than delegating to get_scores_in_region() is worth ~4% on this read, which the position annotator pays once per substitution.

get_scores_in_bins(chrom: str, start: int, end: int, bin_size: int, queries: Sequence[PositionScoreAggregationQuery]) Generator[tuple[int, int, tuple[str | int | float | bool | None, ...]], None, None][source]

Yield one aggregated tuple per grid bin of [start, end].

Bins follow the GLOBAL grid anchored at position 1 (calc_bin_index / calc_bin_begin / calc_bin_end), so adjacent queries tile and results are comparable across calls. Edge bins are clipped to the query, so the yielded bounds name exactly what was aggregated. Every bin in range is emitted, including bins no record touches; a segment straddling a bin boundary contributes its weight to each bin it touches, split at the boundary.

A contig this score never mentions is uncovered rather than refused (gain#1211): every bin of it is emitted, with the bounds a covered contig would yield. That is this read and get_scores_in_region_agg() only; a read that materialises positions still refuses an unknown contig.

get_scores_in_region(chrom: str, start: int, end: int, scores: Sequence[str] | None = None) Generator[tuple[str | int | float | bool | None, ...], None, None][source]

Yield one tuple of score values per position of [start, end].

Exactly end - start + 1 tuples, in position order, None at every position no record covers. scores of None asks for every score this resource defines, in definition order.

get_scores_in_region_agg(chrom: str, start: int, end: int, queries: Sequence[PositionScoreAggregationQuery]) tuple[str | int | float | bool | None, ...][source]

Reduce [start, end] to one value per query, over positions.

Defined on the per-position expansion – an uncovered position is a None, and with a none_value_replacement set it counts – but computed by walking segments, so cost stays proportional to record count. Where two records cover one position the first answers, so accumulated weight never exceeds the region width.

A contig this score never mentions is uncovered rather than refused (gain#1211): it answers as a window on a contig the score has but does not cover. That is this read and the binned one only; a read that materialises positions still refuses an unknown contig.

classmethod record_weight(left: int, right: int) int[source]

A record counts once per base pair it covers.

The only kind whose answer is not 1. That there is exactly one value per position – what a position score PROMISES – is not stated here but in the rules this kind is registered under in gain.genomic_resources.statistics.record_validation, the only places that enforce it. This is a MEASURE.

Elementwise, as the base requires: handed the position columns of a whole batch, the same expression answers that batch’s weights.

resolve_aggregation_queries(queries: Sequence[PositionScoreAggregationQuery]) list[tuple[str, str, str | int | float | bool | None]][source]

Resolve each query to its (score_id, aggregator NAME, replacement).

The third element is the query’s none_value_replacement.

A query asks the same two questions a request list does – which score, and what reduces it – so they are asked where they are answered for every surface, in aggregation (score_def_for(), resolve_aggregator_name()). Only the remedy of the missing-default refusal is this surface’s own, because a caller here names an aggregator on the query rather than in a pair.

What a query asks BESIDES is the third: a none_value_replacement must be of a type the score can mean, following validate_aggregator’s precedent. It is judged BETWEEN the other two – after the score is known, since its value type is what judges the replacement, and before an aggregator is looked for, so that a query wrong in both ways is answered about the value it named rather than the one it left out. That order is a decision and not an accident of composition; it is pinned by test_a_query_invalid_several_ways_reports_the_first_ground.

Public, and stopping at the NAME, because asking whether a query is answerable is a question a caller may have without wanting to read (gain#1131). PositionScoreAnnotator asks it once when the pipeline loads, so an attribute naming no aggregator for a bool score is refused there rather than on the first region that reaches it. Building the accumulators is the READ’s business – _resolve_aggregation_queries() adds them, per call, which is what keeps a read thread-safe and an annotator stateless.

It lives on this kind rather than on GenomicScore because of its middle step: none_value_replacement is a field only a PositionScoreAggregationQuery carries, a position score being the only kind with an uncovered position to speak for. Should a fragment or an allele score come to want a resolver of its own (gain#1124, gain#1132), the score_def_for / resolve_aggregator_name pair is the part that generalises – both already live in aggregation, kind-neutral, for that reason – and the replacement validation is the part that does not.

class gain.genomic_resources.genomic_scores.AlleleScore(resource: GenomicResource)[source]

Allele-specific genomic score resource.

An AlleleScore provides scores that depend on specific alleles at genomic positions. Unlike PositionScore, AlleleScore considers both the reference and alternative alleles when computing scores. This makes it suitable for variant-specific predictions and annotations.

AlleleScore supports two operational modes:

  1. SUBSTITUTIONS mode: Scores are specific to nucleotide substitutions (e.g., A>T, C>G). This mode is optimized for single nucleotide variants and considers the directionality of the change. Used by resources like CADD, which provide substitution-specific scores.

  2. ALLELES mode: Scores are associated with specific alleles at positions, without considering the reference allele. This mode supports insertions, deletions, and more complex variants. The score depends on the alternative allele itself rather than the substitution pattern.

Typical use cases include: - Variant pathogenicity scores (e.g., CADD, DANN) - Functional impact predictions (e.g., PolyPhen, SIFT scores) - Splice site predictions - Regulatory variant scores - Any metric that depends on specific alleles

The score data is typically stored in VCF files or tabix-indexed tables with reference and alternative allele columns.

Example

>>> from gain.genomic_resources.repository_factory import (
...     build_genomic_resource_repository
... )
>>> repo = build_genomic_resource_repository()
>>> resource = repo.get_resource("cadd_v1_6")
>>> score = build_score_from_resource(resource)
>>> with score.open() as score:
...     # Fetch scores for a specific variant
...     values = score.fetch_allele_scores(
...         "chr1", 12345, "A", "T"
...     )
...     # Iterate over the alleles in a region.  The nucleotides
...     # come off the record; the values come off the score.
...     for record in score.fetch_records("chr1", 10000, 20000):
...         values = score.get_score_values_from_record(
...             record, score_defs
...         )
...         print(f"{record[POS_BEGIN]} "
...               f"{record[REF]}>{record[ALT]}: {values}")

Reducing those values over a region is the resource’s own job: get_allele_scores_in_region_agg() folds a region in one streaming walk, one value per ScoreAggregationQuery, with the allele keys beside them when asked, and the allele annotator’s region mode reads through it (gain.annotation.allele_score_annotator).

Variables:
  • resource (gain.genomic_resources.repository.GenomicResource) – The underlying GenomicResource object

  • resource_id – Unique identifier for the resource

  • config (dict) – Configuration dictionary for the score

  • table – GenomicPositionTable for data access (typically VCF)

  • score_definitions (dict[str, ScoreDefT]) – Dictionary mapping score IDs to their definitions

  • mode – Operating mode (SUBSTITUTIONS or ALLELES)

Key Methods:

fetch_allele_scores: Get score values for a specific variant fetch_allele_records: Get the records of a region, filtered, telling a region holding no allele apart from one whose alleles were all rejected get_allele_scores_in_region_agg: Reduce the alleles of a region to one value per query – and their keys – in one walk, telling the same two answers apart fetch_region_segments_scores: Iterate over allele scores in a genomic region substitutions_mode: Check if operating in SUBSTITUTIONS mode alleles_mode: Check if operating in ALLELES mode

Configuration:

The resource configuration should specify:

  • table.filename: Path to the data file (usually VCF)

  • table.reference: Column/field containing reference alleles

  • table.alternative: Column/field containing alternative alleles

  • allele_score_mode: Either “substitutions” or “alleles” (optional)

  • scores: List of score definitions with an optional aggregator specification

class Mode(*values)[source]

Allele score mode.

alleles_mode() bool[source]

Return True if the score is in alleles mode.

fetch_allele_records(chrom: str, pos_begin: int | None, pos_end: int | None, *, score_filter: ScoreFilter | None = None) list[tuple[Any, ...]] | None[source]

Return the allele records overlapping a region, or None.

None means no record overlaps the region at all – absent data. A list means records were there, and holds the ones score_filter accepted, which may be none of them: [] is an empty selection. The two are different answers and a caller may well report them differently, which is the whole reason this read exists rather than GenomicScore.fetch_records() serving the same purpose – an iterator makes both an empty stream.

FragmentScore.fetch_fragment_scores() deliberately has no None, and the difference is in the data rather than in taste: a region is spanned by fragments as a matter of course, so “no fragment covers it” is a count of zero. Allele records sit at points, most of a genome carries none, and a region holding no allele is the same absent data that fetch_allele_scores() already answers None for.

score_filter – from GenomicScore.compile_filter() – is applied inside the read, so a rejected record costs no value extraction and the ownership check covers this path too.

Records, not values: a caller wants the nucleotides and the position as well as the scores, reads several scores off one record, and may read scores this method was never told about. Handing back dicts would settle all three for it, and wrongly. Values come off a record through get_score_value_from_record().

A contig the resource does not have is refused, as the other allele reads refuse it, and refused from the call: answering None would make a caller’s typo indistinguishable from real absent data. This read materialises, so there is no generator body to defer the refusal into – unlike GenomicScore.fetch_records(), which reports it from the first record read.

Materialising is what the list/None answer costs: a caller reading a region far larger than it can hold wants the streaming read instead – or, to reduce the region rather than hold it, get_allele_scores_in_region_agg(), which shares this read’s two answers and its peek. Records the filter rejects are never held, though – only the accepted ones accumulate.

fetch_allele_scores(chrom: str, position: int, reference: str, alternative: str, scores: list[str] | None = None, *, score_filter: ScoreFilter | None = None) dict[str, str | int | float | bool | None] | None[source]

Fetch score values at specified genomic position and nucleotide.

score_filter selects whether this allele is reported at all; an allele it rejects reads as absent, exactly as an unmatched one does.

fetch_region_allele_arrays(chrom: str, pos_begin: int | None, pos_end: int | None, scores: list[str], *, batch_size: int = 100000) Generator[AlleleRecordArrays, None, None][source]

Fetch a region as column arrays, nucleotides included.

GenomicScore.fetch_region_value_arrays() widened by the two columns an allele row has and a position row does not, for a caller scanning a whole region for allele content rather than values – the allele statistics, above all. Each batch is that method’s (pos_begin, pos_end, {score_id: values}) followed by the reference and alternative arrays, as AlleleRecordArrays.

The nucleotides are RAW; the scores beside them are parsed. That asymmetry is deliberate and is the whole contract. A score column goes through its definition’s column parse, so an NA sentinel arrives as that score’s non-value; these two columns go through nothing at all. Whatever the row held is what the array holds – no upper-casing, no stripping, no sentinel handling – because build_tabular_parser() reads them equally verbatim, and a consumer reading a region through this method and a region through GenomicScore.fetch_records() must be handed the same strings rather than two dialects of them. Whoever wants them normalised normalises them, once, where the meaning of the normalisation is known.

Refused, rather than emulated, for a score this facade cannot serve it for – ask supports_region_allele_arrays() first. The guards run when this method is CALLED, not on the first next(), which is why the streaming half lives in _allele_array_batches rather than a yield here.

get_allele_scores_in_region_agg(chrom: str, start: int, end: int, *, queries: Sequence[ScoreAggregationQuery] | None = None, allele_keys: Sequence[str] | None = None, score_filter: ScoreFilter | None = None) AlleleAggregate | None[source]

Reduce the alleles in a region to one value per query, or None.

The kind’s folding read (gain#1132): what fetch_allele_records() would hand back, already reduced, in ONE walk that holds no record. queries of None means every score the resource defines, each with its own default aggregator; a query’s own aggregator wins over the default. An allele line is weighed by record_weight(), which counts it once.

None is absent data, judged before score_filter and with ownership checked first, exactly as fetch_allele_records() answers it – both through _selected_allele_records(), which states the contract. An AlleleAggregate whose fold saw nothing is the other answer: records were there and the filter rejected every one, so each aggregator answers for an empty selection (list gives [], max gives None, …). That asymmetry with the fragment kind’s folding read is a property of the data, and ADR 0017’s Consequences say why.

allele_keys of None – the default – builds no keys, so a caller that wants none pays nothing per record for them. A sequence, possibly empty, asks for the keys and names the score ids to suffix each with: () is the bare chrom:pos:ref:alt. allele_key() states the format; the keys come back distinct and in first-seen order, off the same walk the values were folded from.

The REQUEST is checked when this is called: an unknown score id – in a query or in allele_keys – a query with no aggregator to resolve to, an unknown contig and a foreign filter are all refused before a record is read. Aggregators are built fresh per call (build_region_aggregators()).

static get_schema() dict[str, Any][source]

The GenomicScore schema plus the allele-specific keys.

allele_score_mode, merge_vcf_scores, and the table’s reference, alternative and variant column definitions.

classmethod record_weight(left: int, right: int) int[source]

An allele line counts once.

Several records share a position – one per ref/alt pair – and each weighs 1. Structurally so: fetch_region_segments_scores() yields (pos, pos, values), collapsing the record to a point however wide an optional pos_end column reaches, so a span weight would not merely be a different choice, it would disagree with the per-record read.

A constant, which is elementwise: the base’s record_weights() fills it out to a batch’s shape.

resolve_aggregation_queries(queries: Sequence[ScoreAggregationQuery] | None) list[tuple[str, str]][source]

Resolve each query to its (score_id, aggregator NAME) pair.

The shared resolve_aggregation_queries() on this score, made public so AlleleScoreAnnotator can refuse a bad attribute as the pipeline loads (D6 of the allele folding-read design) – see that function for why it stops at the name.

resolve_allele_key_scores(allele_keys: Sequence[str]) list[GenomicScoreDef][source]

The definitions of the scores an allele-key request suffixes.

The other half of what get_allele_scores_in_region_agg() checks on the call, made askable without reading: the same refusal every allele read gives an unknown score id, with the valid names listed, which is how the annotator refuses a bad include_attributes as the pipeline loads rather than per record.

substitutions_mode() bool[source]

Return True if the score is in substitutions mode.

supports_region_allele_arrays(scores: list[str]) bool[source]

Whether fetch_region_allele_arrays() will serve these scores.

GenomicScore.supports_region_value_arrays() – the backend and the score value types – plus the one condition that is this read’s alone: the table must declare at least one of the two key columns, or there is nothing for it to carry that the shared read does not already give.

The columns are configured independently, and one of them is enough. A table declaring only alternative is served, with the missing side yielded as the None the record carries for it; that is what keeps this read and GenomicScore.fetch_records() the same answer rather than two. A bigWig-backed score is turned away here without being named: it has no such columns to declare.

Answerable on an UNOPENED score, as its counterpart is – and, in one case, conservative there rather than exact. A table’s key columns are resolved when it opens (_set_core_column_keys), from the config and, failing that, from the header. So this asks the same two questions in the same order, using whichever of them can be answered yet: the declaration always, and the header when the table already has one (header_mode: list names it in the config, and an opened table has read it).

That leaves exactly one gap: a header_mode: file table that names its key columns nowhere but inside its own data file answers False until it is opened and True after. The asymmetry is the file’s, not this method’s – a header cannot be known without reading it – and it errs the safe way, because a caller told False reads per-record and gets the same rows.

class gain.genomic_resources.genomic_scores.FragmentScore(resource: GenomicResource)[source]

A genomic score over fragments – intervals carrying attributes.

Nothing here is copy-number specific; a CNV collection is one application of it. Accepts either resource type in FRAGMENT_SCORE_TYPES, warning once per resource on the deprecated one.

fetch_fragment_scores(chrom: str, start: int, stop: int, scores: list[str] | None = None, *, score_filter: ScoreFilter | None = None) Generator[tuple[int, int, tuple[str | int | float | bool | None, ...]], None, None][source]

Stream (begin, end, values) for the fragments over a region.

Private to the fragment plane. fetch_region_segments_scores() through _tupled(), and not a read to reach for directly; it keeps its name because it had one, not because the name is an invitation. It diverges from the internals beside it (_score_segments, _region_read_defs) in spelling only.

What it adds to the base read is the tuple, a locus that is required rather than defaulted, and a list of score ids. What the fragment plane once had a private TWIN of that method for was score_filter, which the base method takes now (gain#1272).

One entry per overlapping fragment, in table order, each reporting the fragment’s OWN extent – unclipped, even where it runs past the region asked for. What a partial overlap means depends on what the caller is computing, so ADR 0008 leaves it to them; a caller that wants the window intersected composes clip_span().

values is positional, parallel to scores as requested (to get_all_scores() when that is None), rather than a mapping: the caller already knows what it asked for and in what order. A value may be None where the record carries no value for that score – unlike the per-position reads, that is the only None here, because a fragment score has no notion of an uncovered position.

score_filter – from GenomicScore.compile_filter() – drops the fragments it rejects, which are then simply not yielded. It reads the RECORD, so it may name any score the resource defines, including one outside scores, and a rejected fragment costs no extraction.

The REQUEST is checked when this is called; the READING is lazy. A closed score, a contig this resource does not have and an unknown score id are refused before the first next() rather than on it, for the reason _region_read_defs() gives. A malformed RECORD is a different matter and is refused when the record is reached: a fragment whose end precedes its begin ends the iteration then, mid-stream.

One live read at a time. A score serves a single region read at once – the table’s line iterator and line buffer are the table’s, not the generator’s – so starting a second read invalidates one that is still being consumed, and on a tabix-backed table the two then answer each other’s records with no error raised. Materialising is what makes a held answer safe to keep:

kept = list(score.fetch_fragment_scores(chrom, beg, end))

Abandoning a read mid-stream is safe and costs only a TabixGenomicPositionTable buffer prune, which gain#1120 moved into a finally – though that runs when the generator is released, so a caller holding a reference to a close()-ed generator still holds the read open.

get_fragment_score_at_position(chrom: str, pos: int, *, score: str | None = None, score_filter: ScoreFilter | None = None) Sequence[tuple[int, int, str | int | float | bool | None]][source]

Return (begin, end, value) per fragment covering a position.

The singular form of get_fragment_scores_at_position(), which says why it materialises; score of None is honoured only when the resource declares exactly one.

get_fragment_score_overlapping_region(chrom: str, start: int, end: int, *, score: str | None = None, score_filter: ScoreFilter | None = None, min_region_overlap_fraction: float | None = None, min_fragment_overlap_fraction: float | None = None) Generator[tuple[int, int, str | int | float | bool | None], None, None][source]

Yield (begin, end, value) per fragment overlapping a region.

The singular form of get_fragment_scores_overlapping_region(), which documents the overlap fractions and the one-live-read limit this inherits; score of None is honoured only when the resource declares exactly one.

get_fragment_score_overlapping_region_agg(chrom: str, start: int, end: int, *, score: str | None = None, aggregator: str | None = None, min_region_overlap_fraction: float | None = None, min_fragment_overlap_fraction: float | None = None, score_filter: ScoreFilter | None = None) FragmentAggregate[source]

Reduce the fragments overlapping a region for ONE score.

The singular form of get_fragment_scores_overlapping_region_agg(), which documents the selection and the reduction; score of None is honoured only when the resource declares exactly one.

Alone among this plane’s singular reads it does NOT unwrap: it answers the same FragmentAggregate, whose values is a one-element tuple. The others have a bare value to answer with; this one’s answer is a count and a reduction together, and the count is a property of the QUERY rather than of the score named – so there is nothing for a bare value to be.

get_fragment_score_starting_in_region(chrom: str, start: int, end: int, *, score: str | None = None, score_filter: ScoreFilter | None = None) Generator[tuple[int, int, str | int | float | bool | None], None, None][source]

Yield (begin, end, value) per fragment BEGINNING in a region.

The singular form of get_fragment_scores_starting_in_region(), which documents the partition it answers and the one-live-read limit this inherits; score of None is honoured only when the resource declares exactly one.

get_fragment_scores_at_position(chrom: str, pos: int, *, scores: list[str] | None = None, score_filter: ScoreFilter | None = None) Sequence[tuple[int, int, tuple[str | int | float | bool | None, ...]]][source]

Return (begin, end, values) per fragment covering a position.

A one-position region read of get_fragment_scores_overlapping_region(), which documents what an entry is; spans are unclipped here too, so a fragment answering a position is reported at its full extent.

Materialised, for the caller’s convenience. A point query returns a handful of fragments, callers want all of them, and a materialised answer can be measured with len(), iterated twice and kept across a later read. It is NOT the drain hazard get_scores_at_position() documents: that was gain#1120’s to fix, and abandoning a region generator has been safe since.

The overlap fractions are deliberately absent. Over a one-base region overlap / region_length is always 1, so the region fraction could only ever be vacuous, and the fragment fraction of a single base is a ratio no caller has been found to want.

pos is refused below 1, through the same _guard_region_span() the region reads use: a backend that reads 0 as “unbounded” would otherwise answer a caller error with the whole contig.

get_fragment_scores_overlapping_region(chrom: str, start: int, end: int, *, scores: list[str] | None = None, score_filter: ScoreFilter | None = None, min_region_overlap_fraction: float | None = None, min_fragment_overlap_fraction: float | None = None) Generator[tuple[int, int, tuple[str | int | float | bool | None, ...]], None, None][source]

Yield (begin, end, values) per fragment overlapping a region.

The plane’s workhorse. Entries are shaped as fetch_fragment_scores() shapes them – one per overlapping fragment, in table order, at the fragment’s OWN unclipped extent, with values positional and parallel to scores – and score_filter behaves as it documents there. What this adds is the two thresholds below.

The two overlap fractions are overlap_fractions_admit(), applied with this region as [start, end] and each fragment as the record; that function defines them. In this plane’s vocabulary min_region_overlap_fraction is “the fragment must cover at least this much of MY region” and min_fragment_overlap_fraction is “at least this much of the FRAGMENT must fall in my region”. Both unset filters nothing – which is what this read did before the thresholds existed – and hands the stream through without consulting the predicate.

They SELECT, they do not RESHAPE: a fragment that passes is still reported at its own unclipped span. That is this plane’s rule and it has no decision record of its own – ADR 0008 is about who validates, not about what a read may do to a span, so it is not the authority for it.

Everything after the locus is keyword-only, and that is not cosmetic: fetch_fragment_scores() takes its score list positionally, so a caller migrating from fetch_fragment_scores(chrom, start, stop, scores) would otherwise bind that list to whatever this signature happens to put fourth – no error, just a plausible-looking filtered result.

The REQUEST is checked when this is called; the READING is lazy. A closed score, an unknown contig, an unknown score id, a region no genomic span can mean and an out-of-range fraction are all refused before the first next().

One live region read per score at a time. The table’s line iterator and line buffer belong to the table, not to the generator, so starting a second read invalidates one that is still being consumed – on a tabix-backed table the two then answer each other’s records with no error raised. A held generator may be closed across another query, never resumed across one. Materialise (list(...)) whatever has to outlive the next read.

get_fragment_scores_overlapping_region_agg(chrom: str, start: int, end: int, *, queries: Sequence[ScoreAggregationQuery] | None = None, min_region_overlap_fraction: float | None = None, min_fragment_overlap_fraction: float | None = None, score_filter: ScoreFilter | None = None) FragmentAggregate[source]

Reduce the fragments overlapping a region to one value per query.

The plane’s folding read: what get_fragment_scores_overlapping_region() yields, already reduced, in ONE pass that also counts what it saw. The selection is that read’s exactly – the two overlap fractions and score_filter mean what they mean there, and a fragment is weighed by record_weight(), which counts it once however long it is. Exactly, because it is the same code: both consume _segments_overlapping_region(), and only the public read tuples what comes out.

Answers a FragmentAggregate, which documents why the count and the values travel together and what count counts.

Deliberately NOT built on aggregate_region(), despite reducing the same way: that surface takes no score_filter, and its CountAggregator has the wrong count semantics here – it skips None values, so it counts non-null VALUES rather than fragments, and answers None rather than 0 for a region no fragment overlaps.

THIS READ holds nothing per fragment: the stream is folded as it arrives and never materialised. Whether the CALL is constant in the number of fragments is then the AGGREGATORS’ business, and they divide three ways:

  • constant – max, min, mean, count, bool;

  • one entry per DISTINCT value – mode, value_count, so bounded by how many values a resource has rather than by how many fragments a region holds;

  • one entry per FRAGMENT – list, median, concatenate and join. join(,) is the DEFAULT for a str score, which makes this the ordinary case for a CNV collection rather than an exotic one.

Under the last group the fold still allocates per fragment. What this read removes is the SECOND copy the annotator used to build beside it, which is a halving there and a flattening everywhere else.

get_fragment_scores_starting_in_region(chrom: str, start: int, end: int, *, scores: list[str] | None = None, score_filter: ScoreFilter | None = None) Generator[tuple[int, int, tuple[str | int | float | bool | None, ...]], None, None][source]

Yield (begin, end, values) per fragment BEGINNING in a region.

Exactly the fragments whose begin lies in [start, end], so a set of adjacent windows answers each fragment from exactly ONE of them: no duplicates and no gaps. That is the property chunked and parallel work depends on, and it is the only predicate on this plane that guarantees it – get_fragment_scores_overlapping_region() answers a fragment from every window it reaches into. The rule is owns_record().

The allele statistics scan makes the same ownership claim inline, as _owns, but spells it clip_span(pos, pos, start, end): an allele row sits AT one position, so for it the record partition and the position one coincide. For a fragment they emphatically do not, which is why this read names the record partition rather than reusing that spelling. That scan is left as it is.

There is no caller yet. It is kept for that meaning, so the partition has a name before something needs it.

Entries are shaped as get_fragment_scores_overlapping_region() shapes them, spans unclipped, and the one-live-read limit it documents applies here too.

The overlap fractions are deliberately absent: this read partitions, and a fraction filter would let a fragment fall out of every window, which is the property being partitioned FOR.

static get_schema() dict[str, Any][source]

The GenomicScore schema plus a per-score aggregator.

classmethod record_weight(left: int, right: int) int[source]

A fragment counts once however long it is.

The kind’s whole reason for weighing by record rather than by span: a fragment is a measured thing, not a run of per-base values, so its length says nothing about how many times its value counts.

A constant, which is elementwise: the base’s record_weights() fills it out to a batch’s shape.

class gain.genomic_resources.genomic_scores.AlleleAggregate(values: tuple[str | int | float | bool | None, ...], allele_keys: tuple[str, ...] | None)[source]

What one folding read reduced a region to, off a single walk.

values is parallel to the QUERIES asked, never keyed by score id: one score asked twice with two aggregators is two queries and therefore two values, which a mapping keyed by score id would silently collapse to one.

allele_keys is None unless the read was asked for them; when asked, the distinct keys in first-seen order (D1, D2 of the allele folding-read design, gain#1132). Built off the same walk the values were folded from, so nothing a caller can do makes the two disagree about which records were seen.

class gain.genomic_resources.genomic_scores.FragmentAggregate(count: int, values: tuple[str | int | float | bool | None, ...])[source]

What one folding read saw, and what it reduced to.

Both halves come off ONE walk of the region, which is the reason they are answered together rather than by two reads a caller would have to trust to agree: nothing a caller can do makes count disagree with the fragments values was folded from.

values is parallel to the QUERIES asked, not keyed by score id. One score requested twice with two aggregators – a source exposed as both a min and a max – is two queries and therefore two values, which a mapping keyed by score id would silently collapse to one.

count is the number of fragments the walk SAW: those overlapping the region, that the overlap fractions admitted, and that score_filter kept. An empty region and a filter that rejected every fragment are both 0 – the distinction gain#820 built for alleles is deliberately not drawn here, keeping ADR 0017’s reasoning that a region is spanned by fragments as a matter of course, so “none cover it” is a count of zero rather than an absence.

class gain.genomic_resources.score_def.GenomicScoreDef(score_id: str, value_type: str, desc: str, small_values_desc: str | None, large_values_desc: str | None, hist_conf: HistogramConfig | None, aggregator: str | None, col_name: str | None, col_index: int | None, value_parser: Any, na_values: Any)[source]

A genomic score definition. Includes backend loading internals.

Extends the shared ScoreDef (score id, value type, description and histogram config) with the concerns that are genomic-only: the per-position and per-allele default aggregators, and the internal column addressing / parsing state used when reading a value off a table backend.

parse_array(cells: ndarray) ndarray[source]

Turn a whole column of raw cells into values, vectorized.

The column half of this definition’s parsing contract, and the reason the bulk statistics scan is worth having. Equivalent to [parse_value(c) for c in cells], with the “no value” that the scalar contract spells None rendered in whatever form the returned array can carry. That equivalence is not an aspiration: test_parse_array_agrees_with_parse_value_fuzz asserts it token by token, per value type, over several na_values configs and several array widths.

The returned array is one of two shapes, chosen by value_type:

  • float and int – a float64 array whose nan is the non-value. A float64 array has no None, and for every consumer of a numeric column a non-value and a nan are the same skip.

  • str – an object array of str, whose None is the non-value, exactly as parse_value() returns it.

Any other value type is refused: bool has no column consumer, and an unset value_type is not a parse this can define.

Parsed with numpy, deliberately NOT with ``pd.to_numeric``, which is not correctly rounded – it returns 9.999999999999999e-26 for 1e-25 and truncates 0.00000071009127180852 to ten significant digits. ndarray.astype agrees with float() on every token tested, including the PEP-515 underscores and Unicode digits pandas rejects outright.

parse_value(value: str | int | float | None) str | int | float | bool | None[source]

Turn one raw cell into this score’s value.

None for a null raw value (an absent VCF INFO key), for a configured NA sentinel, and for a cell that fails to parse – a bad cell is logged and skipped rather than aborting a whole scan.

The scalar half of this definition’s parsing contract; the column half is parse_array(). Both live here, on the object that owns the two inputs they need (value_parser and na_values), so neither can be changed against a config the other did not see.

class gain.genomic_resources.score_filter.ScoreFilter(score: GenomicScore, expression: str, predicate: Callable[[Record], bool])[source]

A compiled record predicate, bound to the score that compiled it.

Opaque on purpose: a caller compiles one through GenomicScore.compile_filter() and passes it back to a fetch, and the tree it was compiled from is nobody else’s business. The source expression is kept for error messages and repr.

It carries the score because its variables are bound to that score’s definitions – a column index, a value type, an NA set – and none of those travel with a record. See require_owner().

require_owner(score: GenomicScore) None[source]

Refuse to read the records of a score that did not compile this.

Checked once per fetch, not per record. The failure it prevents is silent: two resources both defining freq put it at different column indexes, so a foreign filter reads a real value from the wrong column and selects records nobody can tell are wrong.

select(score: GenomicScore, records: Iterable[Record]) Iterator[Record][source]

Yield the records of score this filter accepts.

The only way to apply a filter, and it takes the score being read rather than the records alone, so require_owner() cannot be forgotten: there is no reachable way to test a record against a filter without first saying which score the record came from. The predicate itself stays private for that reason.

Applying the predicate used to be the caller’s own business, and the allele annotator’s region path was the one caller that did it – the single filter application the ownership check could not cover (ADR 0017). It reads through this now, as every other path does.

Lazy, and the ownership check is not: a foreign filter is refused from the call, before any record is read, because it is a programming error rather than a property of the data. A caller that defers this call into a generator body defers the refusal with it – GenomicScore.fetch_records() does, and says so.