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 resultThe 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.pyThere 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
↓
ResultModel2. 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: intThis 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: SequenceMetadataFor our current algorithms, the input section becomes:
inputs
└── sequence
├── identifier
├── description
└── lengthA 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: ToolkitMetadataEvery 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 foundationThe 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: KmerCountOutputTheir structure is easy to read:
KmerCountResult
├── metadata
├── inputs
├── parameters
│ └── kmer
└── output
└── countmetadata 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 += 1That 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: FrequentKmersOutputIts shape is:
FrequentKmersResult
├── metadata
├── inputs
├── parameters
│ └── k_len
└── output
├── kmers
└── frequencyThere 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 8This 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 recordsEach 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 resultsThe 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.pyNotice 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 resultEvery 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"ork_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 PydanticFieldoption 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
DNAobject 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 validatedSequenceobject into compactSequenceMetadata, 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. OurDNAmodel can still be passed because it inherits fromSequence.
- Typed result classes —
KmerCountResultandFrequentKmersResultmake 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 resultThe 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
- Pydantic models: https://docs.pydantic.dev/latest/concepts/models/
- Python
datetime: https://docs.python.org/3/library/datetime.html
Video version:

