Genome Toolkit. Part 4.6: Building Structured Scientific Results

Welcome back to the Genome Toolkit series!

In Part 4.5, we completed the basic input side of Genome Toolkit. We can now load a real multi-record FASTA file, select one record, convert the neutral SequenceRecord into a validated DNA model, and pass its sequence into our existing k-mer algorithms.

Our current workflow is:

FASTA
  ↓
SequenceRecord
  ↓
DNA
  ↓
dna.sequence
  ↓
count_kmer()
find_most_frequent_kmers()

The calculations already work.

In Part 4.6, we are not replacing those algorithms or making the mathematics more complicated. We are making the information going into and coming out of them more consistent, reliable, and useful for scientific work.

We will see the complete before-and-after picture in a moment, then build that change one small piece at a time.

What Are We Trying to Achieve?

Before Part 4.6, the two algorithms look conceptually like this:

count_kmer()

plain string
"AATTTTAAAAC"

+

plain string
"AA"

↓

algorithm

↓

integer
4

and:

find_most_frequent_kmers()

plain string
"AATTTTAAAAC"

+

integer
3

↓

algorithm

↓

list[str]
["TTT", "AAA"]

There is nothing wrong with those calculated values.

The problem is that the result itself does not know where it came from.

If we save:

4

or:

["TTT", "AAA"]

we have lost most of the context that made those values meaningful.

After Part 4.6, the flow becomes:

FASTA file
    ↓
SequenceRecord
    ↓
validated DNA
    ↓
algorithm
    ↓
structured scientific result

The algorithm can still give us the simple value:

# Read only the calculated count from the structured result.
count_result.output.count

or:

# Read only the most frequent k-mer list from the structured result.
frequency_result.output.kmers

but the same result object also contains:

metadata
inputs
parameters
output

That is the entire goal of this part.

For Genome Toolkit today, that validated object is our DNA model.

Later, when we add other biological models such as RNA or Protein, generic algorithms that only need sequence behavior will be able to work with those too.

The FASTA file itself does not validate the biology. Our loader reads FASTA structure and returns a neutral SequenceRecord. The biological model then validates the sequence before the algorithm receives it:

FASTA
  ↓
SequenceRecord
  ↓
DNA validation
  ↓
algorithm

So by the time count_kmer() receives our object, we are no longer handing it an arbitrary string with no biological context.

The Result Shape

Before we build the models, let us define one word we will use throughout this part: metadata.

Metadata simply means data that describes other data. Our calculated result might be 4, but metadata gives us additional information that helps explain that result, such as which algorithm produced it, which Genome Toolkit version was used, and when the calculation happened. It does not change the scientific result itself. It gives that result useful context.

In Genome Toolkit, the calculated value belongs under output, while metadata describes the execution that produced it.

We want every successful Genome Toolkit algorithm result to use the same top-level vocabulary:

metadata
inputs
parameters
output

For count_kmer(), that will look like:

{
  "metadata": {
    "toolkit_version": "0.1.0",
    "algorithm": "count_kmer",
    "timestamp": "<UTC timestamp>"
  },
  "inputs": {
    "sequence": {
      "identifier": "original_example",
      "description": "Original Genome Toolkit test sequence",
      "length": 11
    }
  },
  "parameters": {
    "kmer": "AA"
  },
  "output": {
    "count": 4
  }
}

The result is larger than:

4

but now we know what that 4 actually belongs to.

And when we only want the number, we still write:

# Access the simple calculated count when the extra context is not needed.
result.output.count

So we get both:

simple access
+
full scientific context

Why Use Explicit Result Models?

At first, creating several small Pydantic models for one algorithm may look like unnecessary code.

Inside the calculation, we still use ordinary Python values:

int
str
list
dict

We do not create classes for local counters, dictionaries, loops, or intermediate values.

The models appear only at the important algorithm boundary, where they give us:

predictable fields
runtime validation
attribute access
serialization
JSON Schema
stable API / MCP contracts

Some models currently contain only one field. That is intentional. They define stable sections of a scientific result, not internal algorithm state.

So the rule is simple:

inside the calculation
→ plain Python

at important boundaries
→ explicit typed models

Yes, this creates a little more code. But the repetition is visible and predictable, and each class has one small job. We will only introduce more automation if future algorithms show us, with real evidence, that another abstraction would actually make the code easier to maintain.

Why Keep inputs, parameters, and output Separate?

The distinction is practical.

The biological sequence is the object being analyzed:

inputs.sequence

The k-mer is a setting chosen for this calculation:

parameters.kmer

And the count is what the algorithm calculated:

output.count

For example:

same DNA
  +
kmer = "AA"
  ↓
count = 4

and:

same DNA
  +
kmer = "TT"
  ↓
different result

The biological input stayed the same.

The parameter changed.

A future algorithm may have two biological inputs, or completely different parameters, but it can still follow the same top-level result vocabulary.

Why Will the Algorithms Accept Sequence?

Our k-mer functions currently accept:

# Before Part 4.6, the algorithm accepted a plain sequence string.
sequence: str

That made sense when our project was still working with hardcoded strings.

But now our application already has:

# Our application already has a validated DNA object at this point.
dna

which contains:

identifier
description
validated sequence

Before calling the algorithm, we currently throw that context away:

# Old approach: discard the model context and pass only its string sequence.
seq = dna.sequence

and pass only the string.

In Part 4.6, we stop doing that.

The generic k-mer algorithms will accept:

# New approach: accept the shared validated Sequence model directly.
sequence: Sequence

instead.

Our current DNA class inherits from Sequence, so this works directly:

# DNA inherits from Sequence, so the validated object can be passed directly.
count_kmer(dna, kmer)

The k-mer algorithm does not need any DNA-specific rule. It only needs to:

measure the sequence
index it
slice it

Those behaviors already belong to our shared Sequence model.

That gives us a useful rule for future algorithms:

Use the least-specific validated biological type that provides everything the calculation needs.

For generic k-mer calculations:

Sequence

is enough.

For a DNA-specific algorithm such as reverse complement, the appropriate input would still be:

DNA

Files Changed in This Part

Before writing any code, let us look at the exact files we will touch.

We are adding one new file:

src/genome_toolkit/algorithms/base.py        # <-- NEW

and updating three existing files:

src/genome_toolkit/algorithms/kmer.py        # <-- UPDATED
src/genome_toolkit/algorithms/__init__.py    # <-- UPDATED
application.py                               # <-- UPDATED

Nothing else changes.

Our relevant project structure will become:

genome_toolkit/
├── application.py                          # <-- UPDATED
├── pyproject.toml
├── uv.lock
├── samples/
│   ├── sample.txt
│   └── sample.fasta
└── src/
    └── genome_toolkit/
        ├── __init__.py
        ├── py.typed
        ├── algorithms/
        │   ├── __init__.py                  # <-- UPDATED
        │   ├── base.py                      # <-- NEW
        │   └── kmer.py                      # <-- UPDATED
        ├── load/
        │   ├── __init__.py
        │   ├── fasta.py
        │   ├── records.py
        │   └── text.py
        └── sequence/
            ├── __init__.py
            ├── base.py
            └── dna.py

There are no new dependencies.

Pydantic is already part of Genome Toolkit from Part 4.3, so:

pyproject.toml
uv.lock

stay unchanged.

Our work is focused entirely on the algorithm input and result layer.

1. Create the Shared Result Foundation

We will start with the one new file in this part:

src/genome_toolkit/algorithms/base.py

This file will contain only the result models that our algorithms can share.

We will build it one small class at a time so the inheritance remains easy to follow.

Start with:

"""Shared models for structured algorithm results."""

# Import UTC-aware timestamps for execution metadata.
from datetime import UTC, datetime

# Import the Pydantic tools used by our result models.
from pydantic import BaseModel, ConfigDict, Field

# Record the installed toolkit version in every result.
from genome_toolkit import __version__

# Build compact input metadata from validated Sequence objects.
from genome_toolkit.sequence import Sequence

We already use Pydantic for our biological models, so Part 4.6 does not add a new dependency.

The new standard-library import:

# Import UTC-aware timestamps for result provenance.
from datetime import UTC, datetime

will let us record when a result was created.

We also import:

# Reuse the installed package version in every structured result.
from genome_toolkit import __version__

because the package version is useful provenance. If we return to a saved result later, we want to know which Genome Toolkit version produced it.

1. ResultModel

Add:

# Give every result-related model the same strict configuration.
class ResultModel(BaseModel):
    """Base configuration for algorithm result models."""

    # Reject unexpected fields so the result shape stays predictable.
    model_config = ConfigDict(extra="forbid")

This is the common base for all of our result-related models.

We already met:

# Pydantic ConfigDict controls shared model configuration.
ConfigDict

when building our biological models.

Here:

# Reject unexpected fields so scientific result shapes stay predictable.
extra="forbid"

means Pydantic rejects fields that we did not define.

For example, if a result model expects:

count

but some code accidentally supplies:

counts

the unexpected field is rejected instead of silently changing the shape of our scientific result.

At this point the inheritance is only:

Pydantic BaseModel
        ↓
    ResultModel

2. ToolkitMetadata

Next add:

# Store information about the toolkit execution itself.
class ToolkitMetadata(ResultModel):
    """Toolkit execution information for an algorithm result."""

    # Record the toolkit version that produced the result.
    toolkit_version: str = __version__

    # Record the exact algorithm name.
    algorithm: str

    # Create a fresh UTC timestamp for each new result.
    timestamp: datetime = Field(
        default_factory=lambda: datetime.now(UTC)
    )

This model stores:

toolkit_version
algorithm
timestamp

The version comes from the same package version source we created earlier in the series.

The algorithm name will be supplied when a result is created.

The timestamp is generated automatically.

Our inheritance is now:

ResultModel
└── ToolkitMetadata

Why Use default_factory?

It may be tempting to write:

# This would calculate the timestamp too early, when the class is defined.
timestamp: datetime = datetime.now(UTC)

But that expression would be evaluated when Python creates the class.

We need a fresh timestamp for every result.

So we use:

# Use a factory so every new result receives its own fresh timestamp.
timestamp: datetime = Field(
    default_factory=lambda: datetime.now(UTC)
)

The factory runs each time Pydantic creates a new ToolkitMetadata object:

result 1 → timestamp 1
result 2 → timestamp 2
result 3 → timestamp 3

The small:

# This zero-argument function returns the current UTC time when called.
lambda: datetime.now(UTC)

is simply a function with no arguments that returns the current UTC time.

We use UTC because scientific calculations may run on machines in different time zones.

3. SequenceMetadata

Next add:

# Keep compact information about the analyzed biological sequence.
class SequenceMetadata(ResultModel):
    """Compact information about an analyzed biological sequence."""

    # Preserve the sequence identifier.
    identifier: str

    # Preserve its optional description.
    description: str | None = None

    # Record its length without copying the full sequence.
    length: int

This model stores only:

identifier
description
length

It deliberately does not copy the entire biological sequence.

Real sequences can become very large. Duplicating a complete genome into every saved algorithm result would make even a small calculation produce a huge result.

The real validated Sequence remains the algorithm input.

The result keeps compact information describing what was analyzed.

SequenceMetadata therefore stays a data-only schema. The small sequence_metadata() function will handle the transformation into that schema.

4. sequence_metadata()

SequenceMetadata now has one job:

define the shape of compact sequence metadata

We still need a small reusable way to transform a validated Sequence object into that schema.

Without a helper, every algorithm would have to repeat:

# Manual version: build compact metadata from the validated sequence.
SequenceMetadata(
    identifier=sequence.identifier,
    description=sequence.description,
    length=len(sequence),
)

Instead, add one plain helper function below the model:

# Convert a validated Sequence into our compact result schema.
def sequence_metadata(
    sequence: Sequence,
) -> SequenceMetadata:
    """Build compact metadata from a validated sequence object.

    Args:
        sequence: Validated biological sequence used by an algorithm.

    Returns:
        Compact metadata describing the analyzed sequence.
    """
    # Copy only the small shared fields needed by algorithm results.
    return SequenceMetadata(
        identifier=sequence.identifier,
        description=sequence.description,
        length=len(sequence),
    )

Now our responsibilities stay very clear:

SequenceMetadata
→ defines the data/schema

sequence_metadata()
→ performs the small Sequence → SequenceMetadata transformation

An algorithm can simply write:

# Convert the validated sequence into compact input metadata.
sequence_metadata(sequence)

and keep the repeated metadata plumbing out of the scientific calculation.

5. SequenceInputs

Next add:

# Group one analyzed biological sequence under `inputs.sequence`.
class SequenceInputs(ResultModel):
    """Biological inputs for an algorithm using one sequence."""

    # Store compact sequence metadata rather than the entire sequence.
    sequence: SequenceMetadata

For our current algorithms, the input section becomes:

inputs
└── sequence
    ├── identifier
    ├── description
    └── length

A future algorithm can define another input model if it needs something different.

6. AlgorithmResult

Finally add:

# Give every complete algorithm result the same metadata section.
class AlgorithmResult(ResultModel):
    """Base class for structured Genome Toolkit results."""

    # Record how and when the algorithm result was produced.
    metadata: ToolkitMetadata

Every complete algorithm result will inherit this shared metadata field.

Later:

KmerCountResult
FrequentKmersResult

will inherit from AlgorithmResult.

We deliberately keep AlgorithmResult small. We are not trying to model jobs, workflow engines, users, server responses, storage systems, or complete input sequences.

We only need a reliable base for successful scientific results.

A Simple Mental Model

Before moving on, here is the whole relationship in one place:

ResultModel
    shared Pydantic behavior

ToolkitMetadata
    toolkit version + algorithm + UTC timestamp

SequenceMetadata
    compact information about the analyzed sequence

sequence_metadata()
    Sequence → SequenceMetadata

SequenceInputs
    common input structure for one-sequence algorithms

AlgorithmResult
    shared result foundation

The k-mer-specific result classes we add next will build on that shared foundation.

The important point is that these classes are not adding new scientific calculations. They are giving the inputs and outputs a predictable structure.

base.py Is Finished

That completes the new shared result foundation.

We now have:

ResultModel
ToolkitMetadata
SequenceMetadata
sequence_metadata()
SequenceInputs
AlgorithmResult

Each piece has one small responsibility, and we only had to define the shared structure once.

Now we can move into our existing kmer.py and use that foundation around the real scientific calculations.

Updating kmer.py

Open:

src/genome_toolkit/algorithms/kmer.py

We will update one algorithm at a time.

For each algorithm, we only need to:

1. define its parameters
2. define its output
3. define its full result
4. return that result from the existing calculation

The mathematical loops remain unchanged.

2. Upgrade count_kmer() First

Adding the Result Models

Open:

src/genome_toolkit/algorithms/kmer.py

Its imports now become:

"""K-mer analysis algorithms and structured results."""

# Generic k-mer algorithms need only the shared validated Sequence API.
from genome_toolkit.sequence import Sequence

# Import the shared models and metadata helper used to assemble results.
from .base import (
    AlgorithmResult,
    ResultModel,
    SequenceInputs,
    ToolkitMetadata,
    sequence_metadata,
)

The important new input type is:

# Generic k-mer algorithms now depend on the shared validated Sequence type.
Sequence

We also import:

# Reuse the tiny transformation from Sequence to SequenceMetadata.
sequence_metadata

so each algorithm can build the common input metadata without repeating the same field-copying code.

Then add the three small models used by count_kmer():

# Define the parameter section for count_kmer().
class KmerCountParameters(ResultModel):
    """Parameters passed to `count_kmer()`."""

    # K-mer whose overlapping occurrences we want to count.
    kmer: str


# Define the output section for count_kmer().
class KmerCountOutput(ResultModel):
    """Values computed by `count_kmer()`."""

    # Number of overlapping occurrences found.
    count: int


# Combine metadata, inputs, parameters, and output in one public result.
class KmerCountResult(AlgorithmResult):
    """Structured result returned by `count_kmer()`."""

    # Biological input metadata.
    inputs: SequenceInputs

    # Parameters chosen for this calculation.
    parameters: KmerCountParameters

    # Value calculated by the algorithm.
    output: KmerCountOutput

Their structure is easy to read:

KmerCountResult
├── metadata
├── inputs
├── parameters
│   └── kmer
└── output
    └── count

metadata comes from:

# KmerCountResult inherits the shared metadata field from AlgorithmResult.
AlgorithmResult

The other three sections are defined by the specific result.

Changing the count_kmer() Function Boundary

Before Part 4.6:

# Before Part 4.6: plain string input and primitive integer output.
def count_kmer(
    sequence: str,
    kmer: str,
) -> int:

Now change it to:

# After Part 4.6: validated Sequence input and structured result output.
def count_kmer(
    sequence: Sequence,
    kmer: str,
) -> KmerCountResult:

There are two changes:

input
str → Sequence

output
int → KmerCountResult

The complete function becomes:

# Accept a validated Sequence and return a structured result.
def count_kmer(
    sequence: Sequence,
    kmer: str,
) -> KmerCountResult:
    """Count overlapping occurrences of a k-mer.

    Args:
        sequence: Validated biological sequence to search.
        kmer: K-mer to count, including overlapping occurrences.

    Returns:
        Structured result containing input metadata, parameters, and count.
    """
    # Start the same counter used by our original algorithm.
    kmer_count = 0

    # Visit every valid overlapping starting position.
    for position in range(len(sequence) - (len(kmer) - 1)):
        # Compare the current slice with the requested k-mer.
        if sequence[position : position + len(kmer)] == kmer:
            # Count this matching occurrence.
            kmer_count += 1

    # Package the unchanged count together with its scientific context.
    return KmerCountResult(
        # Record toolkit version, algorithm name, and execution time.
        metadata=ToolkitMetadata(
            algorithm=count_kmer.__name__,
        ),
        # Convert the validated sequence into compact input metadata.
        inputs=SequenceInputs(
            sequence=sequence_metadata(sequence),
        ),
        # Record the k-mer selected for this calculation.
        parameters=KmerCountParameters(
            kmer=kmer,
        ),
        # Store the calculated count.
        output=KmerCountOutput(
            count=kmer_count,
        ),
    )

Look carefully at the calculation:

# Start the existing counter at zero.
kmer_count = 0

# Slide across every valid overlapping k-mer position.
for position in range(len(sequence) - (len(kmer) - 1)):
    # Count each slice matching the requested k-mer.
    if sequence[position : position + len(kmer)] == kmer:
        kmer_count += 1

That is still our existing algorithm.

We did not change:

the loop
the positions
the slice
the comparison
the counter

The difference is what we do after the calculation.

Before:

# Old return: expose only the primitive calculated count.
return kmer_count

Now:

# New return: package that same count with its scientific context.
return KmerCountResult(...)

The computed integer still exists:

# The underlying calculated integer still exists unchanged.
kmer_count

but we place it under:

output.count

alongside the rest of the scientific context.

So despite the extra result models around it, the actual k-mer counting algorithm is still the same few lines of Python. We are standardizing the successful result, not replacing the science.

3. Apply the Same Pattern to find_most_frequent_kmers()

Now we do the same for our second algorithm.

Adding Its Result Models

Add:

# Define the parameter section for find_most_frequent_kmers().
class FrequentKmersParameters(ResultModel):
    """Parameters passed to `find_most_frequent_kmers()`."""

    # Length of the k-mers we want to analyze.
    k_len: int


# Define the output section for the frequent-kmer calculation.
class FrequentKmersOutput(ResultModel):
    """Values computed by `find_most_frequent_kmers()`."""

    # All k-mers tied for the highest observed frequency.
    kmers: list[str]

    # Shared number of occurrences of those k-mers.
    frequency: int


# Combine metadata, inputs, parameters, and output in one public result.
class FrequentKmersResult(AlgorithmResult):
    """Structured result returned by `find_most_frequent_kmers()`."""

    # Biological input metadata.
    inputs: SequenceInputs

    # Parameters chosen for this calculation.
    parameters: FrequentKmersParameters

    # Values calculated by the algorithm.
    output: FrequentKmersOutput

Its shape is:

FrequentKmersResult
├── metadata
├── inputs
├── parameters
│   └── k_len
└── output
    ├── kmers
    └── frequency

There is one useful addition here:

# Preserve the shared highest frequency instead of throwing it away.
frequency: int

Our original algorithm already calculates:

# The existing algorithm already calculates this value internally.
highest_frequency

but previously returned only the list of tied k-mers:

# Previous output exposed only the tied k-mer strings.
["TTT", "AAA"]

That meant we calculated the frequency and then threw it away.

The structured result keeps it.

Changing the Function Boundary

Before:

# Before Part 4.6: plain string input and primitive list output.
def find_most_frequent_kmers(
    sequence: str,
    k_len: int,
) -> list[str]:

Now:

# After Part 4.6: validated Sequence input and structured result output.
def find_most_frequent_kmers(
    sequence: Sequence,
    k_len: int,
) -> FrequentKmersResult:

The complete function is:

# Accept a validated Sequence and return a structured result.
def find_most_frequent_kmers(
    sequence: Sequence,
    k_len: int,
) -> FrequentKmersResult:
    """Find the most frequent k-mers of a requested length.

    Args:
        sequence: Validated biological sequence to analyze.
        k_len: Length of the k-mers to count.

    Returns:
        Structured result containing the most frequent k-mers and their
        shared frequency.
    """
    # Count the occurrences of every observed k-mer.
    kmer_frequencies: dict[str, int] = {}

    # Visit every overlapping k-mer of the requested length.
    for i in range(len(sequence) - k_len + 1):
        # Extract the current k-mer.
        kmer = sequence[i : i + k_len]

        # Increase an existing count or create the first count.
        if kmer in kmer_frequencies:
            kmer_frequencies[kmer] += 1
        else:
            kmer_frequencies[kmer] = 1

    # Find the largest count in the frequency table.
    highest_frequency = max(kmer_frequencies.values())

    # Keep all k-mers tied for that highest frequency.
    frequent_kmers = [
        kmer
        for kmer, frequency in kmer_frequencies.items()
        if frequency == highest_frequency
    ]

    # Package the calculated values together with their scientific context.
    return FrequentKmersResult(
        # Record toolkit version, algorithm name, and execution time.
        metadata=ToolkitMetadata(
            algorithm=find_most_frequent_kmers.__name__,
        ),
        # Convert the validated sequence into compact input metadata.
        inputs=SequenceInputs(
            sequence=sequence_metadata(sequence),
        ),
        # Record the requested k-mer length.
        parameters=FrequentKmersParameters(
            k_len=k_len,
        ),
        # Preserve both the k-mers and their shared frequency.
        output=FrequentKmersOutput(
            kmers=frequent_kmers,
            frequency=highest_frequency,
        ),
    )

Again, the algorithm itself is still familiar.

We still build:

# Existing dictionary that stores the count for each observed k-mer.
kmer_frequencies

We still scan every overlapping k-mer:

# Existing loop: visit every overlapping k-mer of the requested length.
for i in range(len(sequence) - k_len + 1):

We still find:

# The existing algorithm already calculates this value internally.
highest_frequency

And we still keep every k-mer tied at that frequency.

The only difference is that the result now preserves both:

kmers
frequency

together with the input, parameters, and execution metadata.

4. Keep kmer.py Easy to Navigate

After updating both algorithms, the file should remain ordered like this:

KmerCountParameters
KmerCountOutput
KmerCountResult
count_kmer()

FrequentKmersParameters
FrequentKmersOutput
FrequentKmersResult
find_most_frequent_kmers()

This keeps each result structure beside the algorithm that uses it.

We do not need another layer of algorithm classes, result factories, decorators, or orchestration. The shared repetitive pieces live in base.py; the algorithm-specific models and the scientific calculations remain explicit in kmer.py.

If a future algorithm family becomes genuinely large, we can split it then. We do not need speculative subpackages now.

5. Export the Public Result Types

Our public algorithm package currently exposes only the functions.

Update:

src/genome_toolkit/algorithms/__init__.py

to:

"""Bioinformatics algorithms and structured results."""

# Re-export the two public algorithms and their public result types.
from .kmer import (
    FrequentKmersResult,
    KmerCountResult,
    count_kmer,
    find_most_frequent_kmers,
)

# Define the names that form the public algorithms package API.
__all__ = [
    "count_kmer",
    "find_most_frequent_kmers",
    "KmerCountResult",
    "FrequentKmersResult",
]

The normal function API stays simple:

# Import the public k-mer functions through the package API.
from genome_toolkit.algorithms import (
    count_kmer,
    find_most_frequent_kmers,
)

But the two public result classes are now also available:

# Import the public result types when we need them for annotations
# or future external interface declarations.
from genome_toolkit.algorithms import (
    FrequentKmersResult,
    KmerCountResult,
)

That will be useful for type annotations and for future external interfaces that need to declare exactly what an algorithm returns.

We do not export every small nested model such as:

KmerCountParameters
KmerCountOutput
FrequentKmersParameters
FrequentKmersOutput

Those models support the public result structure internally. They do not need to fill the main algorithm namespace.

6. Turn application.py Into a Small Experiment Workflow

We now have all of the pieces we need for a much more useful final application.py.

Instead of loading or rebuilding the same biological sequence for every calculation, we will do the expensive and scientifically important preparation once:

FASTA
  ↓
select one record
  ↓
SequenceRecord
  ↓
validate once
  ↓
DNA

Then we reuse that same validated DNA object across every experiment:

                     ┌→ count_kmer("CCG")
                     ├→ count_kmer("TTCC")
one validated DNA ───┼→ find_most_frequent_kmers(k_len=4)
                     ├→ find_most_frequent_kmers(k_len=5)
                     └→ k_len sweep from 1 to 8

This is an important consequence of the result design we built in this part.

When we pass:

# Reuse the same validated DNA object.
count_kmer(dna, "CCG")

Python passes the existing object to the function. We are not loading the FASTA file again and we are not creating another full DNA sequence for every algorithm run.

The structured result also does not copy the complete biological sequence into its metadata.

Each result keeps only:

identifier
description
length

through SequenceMetadata.

So our experiment can keep one central validated sequence in memory while producing many independent result objects around it:

one full DNA sequence
        ↓
reused by many calculations
        ↓
many compact result records

Each result still has enough provenance to tell us which biological sequence was analyzed, without storing the entire sequence again and again.

That becomes increasingly important when we move from our small sample to sequences containing millions or billions of bases.

Inspect the FASTA File First

Before selecting a sequence, we can inspect the available FASTA records:

# Read only the FASTA headers so we can see which records are available.
fasta_headers = fasta.get_headers(FASTA_SAMPLE)

# Print each zero-based position and parsed header.
for position, header in enumerate(fasta_headers):
    print(f"{position}: {header}")

This lets us choose a real biological record deliberately instead of hardcoding an unknown sequence string.

For our final experiment, we will load:

M57671.1

by identifier.

This is one of the biological sequences in the sample.fasta file we added in Part 4.5. If you want to use exactly the same FASTA data while following this experiment, you can find the latest version in the Genome Toolkit repository:

https://github.com/rebelC0der/Genome_Toolkit/tree/main/samples

The file we use here is:

sample.fasta

Load and Validate the Sequence Once

We then create our central biological object:

# Load one FASTA record by its biological identifier.
fasta_record_1 = fasta.get_sequence(
    FASTA_SAMPLE,
    identifier="M57671.1",
)

# Validate the neutral loader record once as DNA.
dna = DNA.model_validate(
    fasta_record_1,
    from_attributes=True,
)

From this point onward, every experiment reuses:

# One shared validated biological object.
dna

We do not reload the FASTA file for every k-mer calculation.

Run Independent count_kmer() Experiments

We can now run the same algorithm with different parameters:

# Run two independent count experiments against the same DNA object.
count_kmers_run_1 = count_kmer(dna, "CCG")
count_kmers_run_2 = count_kmer(dna, "TTCC")

Each call produces its own structured scientific result.

We can inspect the original sequence directly when we want:

# The full validated sequence still exists once on the DNA object.
print(f"nSequence: {dna.sequence}")

and serialize each experiment independently:

# Serialize each count experiment as its own complete scientific result.
print(
    f"nExperiment #1:n"
    f"{count_kmers_run_1.model_dump_json(indent=2)}"
)
print(
    f"nExperiment #2:n"
    f"{count_kmers_run_2.model_dump_json(indent=2)}"
)

The two results share the same compact sequence context but preserve different algorithm parameters and outputs.

Run Independent Frequent K-mer Experiments

We can do exactly the same with our second algorithm:

# Analyze the same DNA with two different k-mer lengths.
kmer_freq_run_1 = find_most_frequent_kmers(dna, k_len=4)
kmer_freq_run_2 = find_most_frequent_kmers(dna, k_len=5)

Then serialize both runs:

# Print the complete structured results for both frequency experiments.
print(
    f"nExperiment #3:n"
    f"{kmer_freq_run_1.model_dump_json(indent=2)}"
)
print(
    f"nExperiment #4:n"
    f"{kmer_freq_run_2.model_dump_json(indent=2)}"
)

Again, we have not reloaded or revalidated the sequence.

Only the experiment parameter changed.

Sweep Through Several k_len Values

The final experiment shows why clean algorithm boundaries are useful.

Instead of writing eight separate calls, we can sweep through several k-mer lengths:

# Start one compact parameter-sweep experiment.
print("nExperiment #5:")

# Reuse the same DNA object for k-mer lengths from 1 through 8.
for klen in range(1, 9):
    kmers_found = find_most_frequent_kmers(dna, klen)

    # Pull only the values we want for this compact experiment summary.
    print(
        f"k-len: {klen}, "
        f"kmers found: {len(kmers_found.output.kmers)}: "
        f"{kmers_found.output.kmers}"
    )

This is already starting to look less like a single demonstration and more like a lightweight scientific experiment workflow.

One validated input can drive many independent calculations.

The Final application.py

This is the final application checkpoint for Part 4.6:

from pathlib import Path

from genome_toolkit.algorithms import (
    count_kmer,
    find_most_frequent_kmers,
)
from genome_toolkit.load import fasta
from genome_toolkit.sequence import DNA

SAMPLES_DIR = Path(__file__).resolve().parent / "samples"
FASTA_SAMPLE = SAMPLES_DIR / "sample.fasta"

# Insepct the FASTA File headers:
fasta_headers = fasta.get_headers(FASTA_SAMPLE)

for position, header in enumerate(fasta_headers):
    print(f"{position}: {header}")

# Load a sequence from FASTA by identifier
fasta_record_1 = fasta.get_sequence(
    FASTA_SAMPLE,
    identifier="M57671.1",
)

dna = DNA.model_validate(
    fasta_record_1,
    from_attributes=True,
)

count_kmers_run_1 = count_kmer(dna, "CCG")
count_kmers_run_2 = count_kmer(dna, "TTCC")

print(f"\nSequence: {dna.sequence}")

print(f"\nExperiment #1:\n{count_kmers_run_1.model_dump_json(indent=2)}")
print(f"\nExperiment #2:\n{count_kmers_run_2.model_dump_json(indent=2)}")

kmer_freq_run_1 = find_most_frequent_kmers(dna, k_len=4)
kmer_freq_run_2 = find_most_frequent_kmers(dna, k_len=5)

print(f"\nExperiment #3:{kmer_freq_run_1.model_dump_json(indent=2)}")
print(f"\nExperiment #4:{kmer_freq_run_2.model_dump_json(indent=2)}")

print("\nExperiment #5:")

for klen in range(1, 9):
    kmers_found = find_most_frequent_kmers(dna, klen)
    print(f"k-len: {klen}, kmers found: {kmers_found.output.kmers}")

Run:

uv run python application.py

The recorded run shown in this article produces:

0: ('M57671.1', 'Octodon degus insulin mRNA, complete cds')
1: ('ALPHA_GENE_4582', 'one of the alpha proteins')
2: ('BETA_REGULATOR_991', 'a regulatory sequence from the beta cluster')
3: ('GAMMA_OPERON_SEQ3', 'a short sequence from the gamma operon region')
4: ('original_example', 'Original Genome Toolkit test sequence')

Sequence: TGCGTTAGGCTAAACCTTGGGCCCGGCTTAGGCTAAACCTTGGGCCCGGCTTGGGCCCGGCTTAGGCTAAACCTTGGGCCCGGCTTAGGCTAAACCTTGGGCCCGGCTTAGGCTAAACCTTGGGCCGGTTAAGCTTCCGGTTAAGCTTCCGGTTAAGCTTCCGGTTAAGCTTCCGGTTAAGCTTCCGG

Experiment #1:
{
  "metadata": {
    "toolkit_version": "0.1.0",
    "algorithm": "count_kmer",
    "timestamp": "2026-09-10T12:55:09.074227Z"
  },
  "inputs": {
    "sequence": {
      "identifier": "M57671.1",
      "description": "Octodon degus insulin mRNA, complete cds",
      "length": 188
    }
  },
  "parameters": {
    "kmer": "CCG"
  },
  "output": {
    "count": 11
  }
}

Experiment #2:
{
  "metadata": {
    "toolkit_version": "0.1.0",
    "algorithm": "count_kmer",
    "timestamp": "2026-09-10T12:55:09.074287Z"
  },
  "inputs": {
    "sequence": {
      "identifier": "M57671.1",
      "description": "Octodon degus insulin mRNA, complete cds",
      "length": 188
    }
  },
  "parameters": {
    "kmer": "TTCC"
  },
  "output": {
    "count": 5
  }
}

Experiment #3:{
  "metadata": {
    "toolkit_version": "0.1.0",
    "algorithm": "find_most_frequent_kmers",
    "timestamp": "2026-09-10T12:55:09.074464Z"
  },
  "inputs": {
    "sequence": {
      "identifier": "M57671.1",
      "description": "Octodon degus insulin mRNA, complete cds",
      "length": 188
    }
  },
  "parameters": {
    "k_len": 4
  },
  "output": {
    "kmers": [
      "CCGG"
    ],
    "frequency": 11
  }
}

Experiment #4:{
  "metadata": {
    "toolkit_version": "0.1.0",
    "algorithm": "find_most_frequent_kmers",
    "timestamp": "2026-09-10T12:55:09.074538Z"
  },
  "inputs": {
    "sequence": {
      "identifier": "M57671.1",
      "description": "Octodon degus insulin mRNA, complete cds",
      "length": 188
    }
  },
  "parameters": {
    "k_len": 5
  },
  "output": {
    "kmers": [
      "CTTGG",
      "TTGGG",
      "TGGGC",
      "GGGCC"
    ],
    "frequency": 6
  }
}

Experiment #5:
k-len: 1, kmers found: 1: ['G']
k-len: 2, kmers found: 1: ['GG']
k-len: 3, kmers found: 1: ['GGC']
k-len: 4, kmers found: 1: ['CCGG']
k-len: 5, kmers found: 4: ['CTTGG', 'TTGGG', 'TGGGC', 'GGGCC']
k-len: 6, kmers found: 3: ['CTTGGG', 'TTGGGC', 'TGGGCC']
k-len: 7, kmers found: 2: ['CTTGGGC', 'TTGGGCC']
k-len: 8, kmers found: 1: ['CTTGGGCC']

This final application brings together everything we built in Part 4.6.

We load and validate the biological sequence once.

We reuse that same DNA object across multiple independent experiments.

Each algorithm run produces its own structured result.

And each result stores compact provenance about the input without duplicating the complete biological sequence.

7. Final Flow

Our Part 4.6 workflow now looks like this:

FASTA file
    ↓
inspect headers
    ↓
select one record by identifier
    ↓
SequenceRecord
    ↓
validate once
    ↓
DNA
    │
    ├── count_kmer("CCG")
    │      ↓
    │   structured result
    │
    ├── count_kmer("TTCC")
    │      ↓
    │   structured result
    │
    ├── find_most_frequent_kmers(k_len=4)
    │      ↓
    │   structured result
    │
    ├── find_most_frequent_kmers(k_len=5)
    │      ↓
    │   structured result
    │
    └── k_len sweep 1..8
           ↓
        structured results

The full biological sequence exists once on the validated DNA object.

Each algorithm call receives that same object and creates only the result data needed for that run.

Inside the result, SequenceMetadata stores:

identifier
description
length

rather than another copy of:

dna.sequence

So our architecture separates two jobs cleanly:

DNA
→ owns the validated biological sequence

algorithm result
→ records compact provenance + parameters + output

That is a much better foundation for running many experiments against the same biological input.

Before and After

The two diagrams below summarize what changed in Part 4.6.

The first shows our original k-mer functions accepting raw strings and returning primitive Python values. The second shows the finished flow, where biological data comes from FASTA, becomes a validated object, passes through the same scientific algorithms, and returns structured scientific results.

Why This Helps Reproducibility and Provenance

We introduced reproducibility and provenance earlier in this series.

Part 4.6 gives those ideas a concrete place in our code.

A primitive result:

4

contains the computed answer but almost no history.

At the same time, a provenance record does not need to duplicate the complete biological input. Our application keeps the validated DNA object once, while each result records only enough compact sequence metadata to identify what was analyzed.

Our structured result tells us:

which toolkit version ran
which algorithm ran
when it ran
which biological sequence was analyzed
which parameters were used
what the algorithm calculated

That does not magically make every experiment reproducible by itself. A full experiment may also need information about data sources, software environments, preprocessing, and other steps.

But this is a strong foundation.

Instead of throwing away useful context at the moment a result is created, Genome Toolkit begins carrying that context forward.

What We Are Not Solving Yet

Part 4.6 is about the successful result path.

We are not going to mix that work with every algorithm edge case.

For example:

empty k-mer
k_len = 0
negative k_len
k_len greater than sequence length

still need deliberate behavior.

Those cases belong in Part 4.7, where we will write tests first and then define the validation and exceptions those tests show we actually need.

Keeping those concerns separate lets us answer two different questions clearly:

Part 4.6
What does a successful scientific result look like?
Part 4.7
What should happen when an input or source is invalid?

Our Final Project Structure

Part 4.6 changes one application file and the algorithm package.

The complete current structure is:

genome_toolkit/
├── .git/
├── .gitignore
├── .venv/
├── README.md
├── application.py                  # <-- UPDATED
├── pyproject.toml
├── uv.lock
├── samples/
│   ├── sample.txt
│   └── sample.fasta
└── src/
    └── genome_toolkit/
        ├── __init__.py
        ├── py.typed
        ├── algorithms/
        │   ├── __init__.py          # <-- UPDATED
        │   ├── base.py              # <-- NEW
        │   └── kmer.py              # <-- UPDATED
        ├── load/
        │   ├── __init__.py
        │   ├── fasta.py
        │   ├── records.py
        │   └── text.py
        └── sequence/
            ├── __init__.py
            ├── base.py
            └── dna.py

Notice what did not change:

samples/
load/
sequence/
pyproject.toml
uv.lock

We did not add a dependency, redesign the FASTA loader, or change our DNA validation.

Part 4.6 is focused on the algorithm boundary and the scientific result layer.

Summary

In Part 4.6, we changed both sides of our k-mer algorithms.

Before:

raw str
  ↓
algorithm
  ↓
int / list[str]

Now:

validated Sequence
        ↓
same scientific calculation
        ↓
typed structured result

Every result follows the same predictable structure:

metadata
inputs
parameters
output

We created algorithms/base.py for the small result components shared across algorithms, then upgraded both k-mer functions without changing their scientific calculations.

Our final application.py also demonstrates an important scientific usage pattern:

load once
validate once
reuse many times

We load M57671.1 from FASTA once, create one validated DNA object, and reuse that same object across several independent k-mer experiments.

The structured results do not store another full copy of the sequence. They keep only compact sequence metadata:

identifier
description
length

alongside the algorithm metadata, parameters, and calculated output.

That gives us a useful balance:

one central validated biological object
+
many independent compact scientific results

The final parameter sweep also shows why this matters in practice. Once the input and output boundaries are clean, running a series of related experiments becomes simple Python rather than repeated data-loading code.

Most importantly, the k-mer mathematics itself did not change. We improved how biological inputs are reused and how scientific results preserve their context.

New Concepts We Learned

  • Structured scientific result — A typed result object that keeps the calculated value together with useful information about the input, parameters, software, and execution.
  • Metadata — Data that describes other data. In our results, metadata gives the calculated output extra context, such as the Genome Toolkit version, algorithm name, and execution timestamp.
  • Inputs — The biological objects analyzed by an algorithm. For our current k-mer functions, this is compact metadata describing one sequence.
  • Parameters — Settings chosen for a particular algorithm run, such as kmer="AA" or k_len=3.
  • Output — The values actually computed by the algorithm, such as a count, a list of frequent k-mers, or their shared frequency.
  • default_factory — A Pydantic Field option that calls a function whenever a new model instance needs its default value. We use it so every result receives a fresh UTC timestamp.
  • UTC timestamp — A time recorded using a shared global time standard instead of a machine’s local time zone.
  • Compact sequence metadata — Instead of copying a potentially huge biological sequence into every result, we preserve its identifier, optional description, and length.
  • Reusable validated input — We can load and validate one biological sequence once, then pass the same DNA object into many independent algorithm runs without reloading or duplicating the complete sequence for every experiment.
  • sequence_metadata() — A small plain helper function that converts a validated Sequence object into compact SequenceMetadata, while keeping the model itself focused only on the result schema.
  • Least-specific validated input type — A generic k-mer calculation needs sequence behavior but no DNA-specific rule, so it accepts Sequence. Our DNA model can still be passed because it inherits from Sequence.
  • Typed result classesKmerCountResult and FrequentKmersResult make each algorithm’s successful return structure explicit to Python, editors, external callers, and future interfaces.
  • Serialization — Pydantic can convert the same structured Python result into JSON with model_dump_json(), without creating a separate algorithm implementation.

The central idea is simple:

A scientific calculation should not lose its context the moment it returns a value.

What is Next?

Genome Toolkit now has a much clearer successful-computation path:

biological data
      ↓
SequenceRecord
      ↓
validated biological model
      ↓
algorithm
      ↓
typed structured scientific result

The next question is what happens when something goes wrong.

Our loaders and algorithms still have edge cases such as:

invalid source structure
missing records
empty k-mer
invalid k-mer length

In Part 4.7, we will add automated tests and use those tests to define a clear failure contract for Genome Toolkit.

We will add only the validation and custom exceptions that our actual tests show we need.

The full source code for Genome Toolkit is available here:

https://github.com/rebelC0der/Genome_Toolkit

I hope building typed scientific results, reusing one validated DNA sequence across multiple experiments, and preserving compact provenance for every algorithm run was useful for your bioinformatics and programming journey! If you found this article valuable and want to help us continue building rebelScience, please consider supporting our project. You can explore various ways to contribute here:

https://rebelscience.club/cryptocurrency-donations/

Until next time, rebelCoder, signing out.

References

Video version:

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.