Repository: monarch-initiative/gpsea (language: python)

Issue:
Fix Q/C reporting
Fixes #327 by fixing an operator precedence bug in `PreprocessingValidationResult.summarize`.

Fixes #286 by using `stairval` for error reporting.


Discussion / hints:

Fix patch:
diff --git a/docs/conf.py b/docs/conf.py
index c49324de..48b41634 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -60,9 +60,9 @@
 # built documents.
 #
 # The short X.Y version.
-version = u'0.6'
+version = u'0.7'
 # The full version, including alpha/beta/rc tags.
-release = u'0.6.0'
+release = u'0.7.0'
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff --git a/docs/img/umod_km_curves.png b/docs/img/umod_km_curves.png
new file mode 100644
index 00000000..760b8ab9
Binary files /dev/null and b/docs/img/umod_km_curves.png differ
diff --git a/docs/report/tbx5_cohort_info.html b/docs/report/tbx5_cohort_info.html
index 0f68dc7a..c8514663 100644
--- a/docs/report/tbx5_cohort_info.html
+++ b/docs/report/tbx5_cohort_info.html
@@ -64,7 +64,13 @@
 
 <body>
   <h1>GPSEA cohort analysis</h1>
-  <p>Successfully loaded 156 individuals.</p>
+  <p>Successfully loaded 156 individuals. 46 were recorded as male, 56 as female,
+      and 54 as unknown sex. 156 were reported to be alive at the time of last
+      encounter, and 0 were deceased. 1 had disease onset information
+      and 51 had information about the age at last encounter. In the cohort,
+      there were a total of 117 HPO terms used for annotation.
+    
+  </p>
   
   <p>No errors encountered.</p>
   
@@ -72,7 +78,7 @@ <h1>GPSEA cohort analysis</h1>
     <table>
     <caption style="color: black;">
         <h3>Top 10 HPO Terms</h3>
-        A total of 116 HPO terms were used to annotated the cohort.
+        A total of 117 HPO terms were used to annotated the cohort.
     </caption>
         <tbody>
             <tr class="strng">
@@ -106,14 +112,14 @@ <h3>Top 10 HPO Terms</h3>
                 </tr>
             
             <tr>
-                <td class="lft">Absent thumb</td>
-                <td>HP:0009777</td>
+                <td class="lft">Short thumb</td>
+                <td>HP:0009778</td>
                 <td>32</td>
                 </tr>
             
             <tr>
-                <td class="lft">Short thumb</td>
-                <td>HP:0009778</td>
+                <td class="lft">Absent thumb</td>
+                <td>HP:0009777</td>
                 <td>32</td>
                 </tr>
             
@@ -144,14 +150,16 @@ <h3>Top 10 HPO Terms</h3>
                 </tbody>
     </table>
 
+  
+
     <table>
     <caption style="color: black;">
         <h3>Top 10 Variants</h3>
-        Variants are shown according to NM_181486.4. A total of 156 unique variants were identified in the cohort.
+        Variants are shown according to NM_181486.4. A total of 53 unique variants were identified in the cohort.
     </caption>
             <tbody>
                 <tr class="strng">
-                    <th>Count</th>
+                    <th>Seen in <em>n</em> individuals</th>
                     <th class="lft">Variant key</th>
                     <th>Variant Name</th>
                     <th>Protein Variant</th>
@@ -190,6 +198,14 @@ <h3>Top 10 Variants</h3>
                     <td>STOP_GAINED</td>
                 </tr>
                 
+                <tr>
+                    <td>5</td>
+                    <td class="lft">12_114399514_114399514_A_C</td>
+                    <td>c.361T>G</td>
+                    <td>p.Trp121Gly</td>
+                    <td>MISSENSE_VARIANT, SPLICE_REGION_VARIANT</td>
+                </tr>
+                
                 <tr>
                     <td>5</td>
                     <td class="lft">12_114398682_114398682_C_CG</td>
@@ -206,14 +222,6 @@ <h3>Top 10 Variants</h3>
                     <td>FRAMESHIFT_VARIANT</td>
                 </tr>
                 
-                <tr>
-                    <td>5</td>
-                    <td class="lft">12_114399514_114399514_A_C</td>
-                    <td>c.361T>G</td>
-                    <td>p.Trp121Gly</td>
-                    <td>MISSENSE_VARIANT, SPLICE_REGION_VARIANT</td>
-                </tr>
-                
                 <tr>
                     <td>4</td>
                     <td class="lft">12_114385474_114385474_A_G</td>
@@ -232,10 +240,10 @@ <h3>Top 10 Variants</h3>
                 
                 <tr>
                     <td>4</td>
-                    <td class="lft">12_114366360_114366360_C_T</td>
-                    <td>c.787G>A</td>
-                    <td>p.Val263Met</td>
-                    <td>MISSENSE_VARIANT</td>
+                    <td class="lft">12_114403798_114403798_G_GC</td>
+                    <td>c.100dup</td>
+                    <td>p.Ala34GlyfsTer27</td>
+                    <td>FRAMESHIFT_VARIANT</td>
                 </tr>
                 
             </tbody>
@@ -268,61 +276,73 @@ <h3>Variant categories for NM_181486.4</h3>
             <tr class="strng">
                 <th class="lft">Variant effect</th>
                 <th>Annotation Count</th>
+                <th>Percent</th>
               </tr>
               
               <tr>
-                <td class="lft">FRAMESHIFT_VARIANT</td>
-                <td>38</td>
+                <td class="lft">MISSENSE_VARIANT</td>
+                <td>85</td>
+               <td>50%</td>
                 </tr>
             
               <tr>
-                <td class="lft">MISSENSE_VARIANT</td>
-                <td>85</td>
+                <td class="lft">FRAMESHIFT_VARIANT</td>
+                <td>38</td>
+               <td>22%</td>
                 </tr>
             
               <tr>
                 <td class="lft">STOP_GAINED</td>
                 <td>19</td>
+               <td>11%</td>
                 </tr>
             
               <tr>
                 <td class="lft">SPLICE_REGION_VARIANT</td>
                 <td>10</td>
+               <td>6%</td>
                 </tr>
             
               <tr>
-                <td class="lft">SPLICE_ACCEPTOR_VARIANT</td>
-                <td>2</td>
+                <td class="lft">SPLICE_DONOR_VARIANT</td>
+                <td>7</td>
+               <td>4%</td>
                 </tr>
             
               <tr>
-                <td class="lft">SPLICE_DONOR_VARIANT</td>
-                <td>7</td>
+                <td class="lft">INFRAME_INSERTION</td>
+                <td>2</td>
+               <td>1%</td>
                 </tr>
             
               <tr>
-                <td class="lft">SPLICE_DONOR_5TH_BASE_VARIANT</td>
+                <td class="lft">STOP_RETAINED_VARIANT</td>
                 <td>2</td>
+               <td>1%</td>
                 </tr>
             
               <tr>
-                <td class="lft">INTRON_VARIANT</td>
+                <td class="lft">SPLICE_ACCEPTOR_VARIANT</td>
                 <td>2</td>
+               <td>1%</td>
                 </tr>
             
               <tr>
-                <td class="lft">INFRAME_INSERTION</td>
+                <td class="lft">SPLICE_DONOR_5TH_BASE_VARIANT</td>
                 <td>2</td>
+               <td>1%</td>
                 </tr>
             
               <tr>
-                <td class="lft">STOP_RETAINED_VARIANT</td>
+                <td class="lft">INTRON_VARIANT</td>
                 <td>2</td>
+               <td>1%</td>
                 </tr>
             
               <tr>
                 <td class="lft">INFRAME_DELETION</td>
                 <td>1</td>
+               <td>1%</td>
                 </tr>
             
            </tbody>
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index 33056c03..48972404 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -206,8 +206,8 @@ in the individuals of the *TBX5* cohort.
 ...     b_predicate=VariantPredicates.variant_effect(VariantEffect.FRAMESHIFT_VARIANT, tx_id),
 ...     a_label="Missense", b_label="Frameshift",
 ... )
->>> gt_predicate.display_question()
-'Allele group: Missense, Frameshift'
+>>> gt_predicate.group_labels
+('Missense', 'Frameshift')
 
 .. note::
 
diff --git a/docs/user-guide/analyses/measurements.rst b/docs/user-guide/analyses/measurements.rst
index 0d9f0509..6d1e078c 100644
--- a/docs/user-guide/analyses/measurements.rst
+++ b/docs/user-guide/analyses/measurements.rst
@@ -1,4 +1,4 @@
-.. _measurement-stat:
+`.. _measurement-stat:
 
 
 ==========================
@@ -57,7 +57,7 @@ TODO - create real predicate.
 >>> from gpsea.model import VariantEffect
 >>> from gpsea.analysis.predicate.genotype import VariantPredicates
 >>> is_missense = VariantPredicates.variant_effect(VariantEffect.MISSENSE_VARIANT, tx_id=tx_id)
->>> is_missense.get_question()
+>>> is_missense.description
 'MISSENSE_VARIANT on NM_000500.9'
 
 Assuming AR inheritance, we compare missense vs. rest:
@@ -69,8 +69,8 @@ Assuming AR inheritance, we compare missense vs. rest:
 ...     a_label="Missense", b_label="Other",
 ...     partitions=({0,}, {1, 2}),
 ... )
->>> gt_predicate.display_question()
-'Allele group: Missense/Missense, Missense/Other OR Other/Other'
+>>> gt_predicate.group_labels
+('Missense/Missense', 'Missense/Other OR Other/Other')
 
 Phenotype score
 ---------------
@@ -84,7 +84,10 @@ We use the measurement of `Testosterone [Mass/volume] in Serum or Plasma <https:
 >>> testosterone = 'LOINC:2986-8'
 >>> pheno_scorer = MeasurementPhenotypeScorer.from_measurement_id(
 ...     term_id=testosterone,
+...     label="Testosterone [Mass/volume] in Serum or Plasma",
 ... )
+>>> pheno_scorer.description
+'Value of Testosterone [Mass/volume] in Serum or Plasma [LOINC:2986-8]'
 
 
 Statistical test
@@ -124,15 +127,15 @@ We execute the analysis by running
 
 Show data frame with scores
 
->>> scores = result.genotype_phenotype_scores.sort_index()
+>>> scores = result.data.sort_index()
 >>> scores.head()  # doctest: +NORMALIZE_WHITESPACE
-                                     genotype phenotype
-patient_id                                                   
-individual 10[PMID_30968594_individual_10]        1     614.0
-individual 11[PMID_30968594_individual_11]        1     630.0
-individual 12[PMID_30968594_individual_12]        1       NaN
-individual 13[PMID_30968594_individual_13]        1     303.0
-individual 14[PMID_30968594_individual_14]        1     664.0
+                                            genotype  phenotype
+patient_id                                                     
+individual 10[PMID_30968594_individual_10]         1      614.0
+individual 11[PMID_30968594_individual_11]         1      630.0
+individual 12[PMID_30968594_individual_12]         1        NaN
+individual 13[PMID_30968594_individual_13]         1      303.0
+individual 14[PMID_30968594_individual_14]         1      664.0
 
 
 Prepare genotype category legend:
diff --git a/docs/user-guide/analyses/phenotype-groups.rst b/docs/user-guide/analyses/phenotype-groups.rst
index 21300a91..86b72f59 100644
--- a/docs/user-guide/analyses/phenotype-groups.rst
+++ b/docs/user-guide/analyses/phenotype-groups.rst
@@ -106,42 +106,42 @@ Genotype predicate
 ------------------
 
 We want to separate the patients into two groups: a group *with* a frameshift variant
-and a group *without* a frameshift variant, based on the functional annotation.
+and a group *without* a frameshift variant (i.e. any other heterozygous variant).
 We will use the *MANE* transcript for the analysis:
 
+>>> tx_id = 'NM_181486.4'
+
 Building a genotype predicate is a two step process. 
 First, we create a :class:`~gpsea.analysis.predicate.genotype.VariantPredicate`
-to test if the variant leads to a frameshift (in this case):
+to test if the variant is predicted to lead to a frameshift in `NM_181486.4`:
 
 >>> from gpsea.model import VariantEffect
 >>> from gpsea.analysis.predicate.genotype import VariantPredicates
->>> tx_id = 'NM_181486.4'
 >>> is_frameshift = VariantPredicates.variant_effect(VariantEffect.FRAMESHIFT_VARIANT, tx_id)
->>> is_frameshift.get_question()
+>>> is_frameshift.description
 'FRAMESHIFT_VARIANT on NM_181486.4'
 
-and then we choose the expected mode of inheritance to test. In case of *TBX5*,
-we expect the autosomal dominant mode of inheritance:
-
->>> from gpsea.analysis.predicate.genotype import autosomal_dominant
->>> gt_predicate = autosomal_dominant(is_frameshift)
->>> gt_predicate.display_question()
-'What is the genotype group: No allele, Monoallelic'
+and then we wrap `is_frameshift` in a :class:`~gpsea.analysis.predicate.genotype.monoallelic_predicate` 
+to classify each *TBX5* cohort member either as an individual with one frameshift allele (`Frameshift`)
+or as an idividual with one non-frameshift allele (`Other`):
 
-`gt_predicate` will assign the patients with no frameshift variant allele into `No allele` group
-and the patients with one frameshift allele will be assigned into `Monoallelic` group.
-Note, any patient with 2 or more alleles will be *omitted* from the analysis.
-
-.. note::
+>>> from gpsea.analysis.predicate.genotype import monoallelic_predicate
+>>> gt_predicate = monoallelic_predicate(
+...     a_predicate=is_frameshift,
+...     a_label="Frameshift",
+...     b_label="Other",
+... )
+>>> gt_predicate.group_labels
+('Frameshift', 'Other')
 
-   Mode of inheritance testing is not the only way to dissect by a genotype.
-   See the :ref:`genotype-predicates` section for more info.
+In the subsequent analysis, `gt_predicate` will assign a cohort member into the respective group.
+Note, any patient with :math:`0` or :math:`\ge 2` alleles will be *omitted* from the analysis.
 
 
 Phenotype predicates
 --------------------
 
-We recommend testing the genotype phenotype association for all HPO terms that are present in 2 or more cohort members,
+We recommend testing the genotype phenotype association for all HPO terms that annotate the cohort members,
 while taking advantage of the HPO graph structure and of the :ref:`true-path-rule`.
 We will use the :func:`~gpsea.analysis.predicate.phenotype.prepare_predicates_for_terms_of_interest`
 utility function to generate phenotype predicates for all HPO terms.
@@ -170,7 +170,7 @@ including the *indirect* annotations whose presence is implied by the :ref:`true
 Statistical test
 ----------------
 
-We will use :ref:<fisher-exact-test> to test the association
+We will use :ref:`fisher-exact-test` to test the association
 between genotype and phenotype groups, as described previously.
 
 >>> from gpsea.analysis.pcats.stats import FisherExactTest
@@ -182,10 +182,10 @@ FET will compute a p value for each genotype phenotype group.
 Multiple testing correction
 ---------------------------
 
-In the case of this cohort, we could test association between having a frameshift variant and one of 260 HPO terms.
+In the case of this cohort, we could test association between having a frameshift variant and one of 369 HPO terms.
 However, testing multiple hypotheses on the same dataset increases the risk of finding a significant association
 by chance.
-GPSEA uses a two-pronged strategy to mitigate this risk - use Phenotype MTC filter and multiple testing correction.
+GPSEA uses a two-pronged strategy to mitigate this risk - a phenotype MTC filter and multiple testing correction.
 
 .. note::
 
@@ -270,10 +270,10 @@ ordered by the corrected p value (Benjamini-Hochberg FDR):
 
 
 The table shows that several HPO terms are significantly associated
-with presence of a heterozygous (`Monoallelic`) frameshift variant in *TBX5*.
+with presence of a heterozygous (`Frameshift`) frameshift variant in *TBX5*.
 For example, `Ventricular septal defect <https://hpo.jax.org/browse/term/HP:0001629>`_
-was observed in 42/71 (59%) patients with no frameshift allele (`No allele`)
-but it was observed in 19/19 (100%) patients with a frameshift allele (`Monoallelic`).
+was observed in 42/71 (59%) patients with no frameshift allele (`Other`)
+but it was observed in 19/19 (100%) patients with a frameshift allele (`Frameshift`).
 Fisher exact test computed a p value of `~0.000242`
 and the p value corrected by Benjamini-Hochberg procedure
 is `~0.005806`.
diff --git a/docs/user-guide/analyses/phenotype-scores.rst b/docs/user-guide/analyses/phenotype-scores.rst
index 91525d5d..ca95e2d8 100644
--- a/docs/user-guide/analyses/phenotype-scores.rst
+++ b/docs/user-guide/analyses/phenotype-scores.rst
@@ -105,8 +105,8 @@ In this example, the point mutation is a mutation that meets the following condi
 >>> point_mutation = VariantPredicates.change_length('==', 0) \
 ...     & VariantPredicates.ref_length('==', 1) \
 ...     & VariantPredicates.any(VariantPredicates.variant_effect(effect, tx_id) for effect in point_mutation_effects)
->>> point_mutation.get_question()
-'((change length == 0 AND ref allele length == 1) AND MISSENSE_VARIANT on NM_001042681.2)'
+>>> point_mutation.description
+'((change length == 0 AND reference allele length == 1) AND MISSENSE_VARIANT on NM_001042681.2)'
 
 
 For the loss of function predicate, the following variant effects are considered loss of function:
@@ -118,7 +118,7 @@ For the loss of function predicate, the following variant effects are considered
 ...     VariantEffect.STOP_GAINED,
 ... )
 >>> lof_mutation = VariantPredicates.any(VariantPredicates.variant_effect(eff, tx_id) for eff in lof_effects)
->>> lof_mutation.get_question()
+>>> lof_mutation.description
 '(TRANSCRIPT_ABLATION on NM_001042681.2 OR FRAMESHIFT_VARIANT on NM_001042681.2 OR START_LOST on NM_001042681.2 OR STOP_GAINED on NM_001042681.2)'
 
 
@@ -130,8 +130,8 @@ The genotype predicate will bin the patient into two groups: a point mutation gr
 ...     b_predicate=lof_mutation,
 ...     a_label="Point", b_label="LoF",
 ... )
->>> gt_predicate.display_question()
-'Allele group: Point, LoF'
+>>> gt_predicate.group_labels
+('Point', 'LoF')
 
 
 .. _phenotype-score:
@@ -191,6 +191,8 @@ We construct the scorer with
 ...     hpo=hpo,
 ...     query=structural_defects,
 ... )
+>>> pheno_scorer.description
+'Assign a phenotype score that is equivalent to the count of present phenotypes that are either an exact match to the query terms or their descendants'
 
 
 Statistical test
@@ -242,18 +244,18 @@ with point vs. loss-of-function mutations.
 
 To explore further, we can access a data frame with genotype categories and phenotype counts:
 
->>> scores = result.genotype_phenotype_scores.sort_index()
+>>> scores = result.data.sort_index()
 >>> scores.head()  # doctest: +NORMALIZE_WHITESPACE
-                                     genotype phenotype
+                                      genotype  phenotype
 patient_id
-Subject 10[PMID_27087320_Subject_10]        1         0
-Subject 1[PMID_27087320_Subject_1]          0         4
-Subject 1[PMID_29330883_Subject_1]          1         0
-Subject 2[PMID_27087320_Subject_2]       None         4
-Subject 2[PMID_29330883_Subject_2]          1         1
+Subject 10[PMID_27087320_Subject_10]         1          0
+Subject 1[PMID_27087320_Subject_1]           0          4
+Subject 1[PMID_29330883_Subject_1]           1          0
+Subject 2[PMID_27087320_Subject_2]        None          4
+Subject 2[PMID_29330883_Subject_2]           1          1
 
 
-The data frame provides a `genotype` category and a `phenotype` score for each patient.
+The data frame provides a `genotype` category and a `phenotype_score` for each patient.
 The genotype category should be interpreted in the context of the genotype predicate:
 
 >>> gt_id_to_name = {c.category.cat_id: c.category.name for c in gt_predicate.get_categorizations()}
@@ -270,14 +272,12 @@ to visualize the phenotype score distributions:
 >>> import matplotlib.pyplot as plt
 >>> fig, ax = plt.subplots(figsize=(6, 4), dpi=120)
 >>> result.plot_boxplots(
-...     gt_predicate=gt_predicate,
 ...     ax=ax,
 ... )
 >>> _ = ax.grid(axis="y")
 >>> _ = ax.set(
 ...     ylabel="Phenotype score", ylim=(-0.5, len(structural_defects) + 0.5)
 ... )
->>> fig.savefig('docs/img/rere_phenotype_score_boxplot.png')  # doctest: +SKIP
 
 
 .. image:: /img/rere_phenotype_score_boxplot.png
@@ -285,6 +285,12 @@ to visualize the phenotype score distributions:
    :align: center
    :width: 600px
 
+.. doctest:: phenotype-scores
+   :hide:
+
+   >>> fig.savefig('docs/img/rere_phenotype_score_boxplot.png')  # doctest: +SKIP
+
+
 
 We see that the individuals with the point mutations feature structural defects
 than the individuals with the loss-of-function mutations.
diff --git a/docs/user-guide/analyses/report/tbx5_frameshift.csv b/docs/user-guide/analyses/report/tbx5_frameshift.csv
index f02767d1..a9c035a6 100644
--- a/docs/user-guide/analyses/report/tbx5_frameshift.csv
+++ b/docs/user-guide/analyses/report/tbx5_frameshift.csv
@@ -1,26 +1,26 @@
-What is the genotype group,No allele,No allele,Monoallelic,Monoallelic,,
+Allele group,Frameshift,Frameshift,Other,Other,,
 ,Count,Percent,Count,Percent,Corrected p values,p values
-Ventricular septal defect [HP:0001629],42/71,59%,19/19,100%,0.005806240832840839,0.00024192670136836825
-Absent thumb [HP:0009777],18/100,18%,14/31,45%,0.044564058192239124,0.003713671516019927
-Secundum atrial septal defect [HP:0001684],23/55,42%,4/22,18%,0.4065940176561687,0.06544319142266644
-Triphalangeal thumb [HP:0001199],23/99,23%,13/32,41%,0.4065940176561687,0.06932119159387057
-Muscular ventricular septal defect [HP:0011623],8/84,10%,6/25,24%,0.4065940176561687,0.08470708701170182
-Short thumb [HP:0009778],25/69,36%,8/30,27%,1.0,0.48700997145537483
-Absent radius [HP:0003974],9/43,21%,6/25,24%,1.0,0.7703831604944444
-Abnormal cardiac septum morphology [HP:0001671],89/89,100%,28/28,100%,1.0,1.0
-Abnormal hand morphology [HP:0005922],75/75,100%,20/20,100%,1.0,1.0
-Atrial septal defect [HP:0001631],63/65,97%,20/20,100%,1.0,1.0
-Abnormal atrial septum morphology [HP:0011994],64/64,100%,20/20,100%,1.0,1.0
-Abnormal cardiac atrium morphology [HP:0005120],64/64,100%,20/20,100%,1.0,1.0
-Hypoplasia of the radius [HP:0002984],34/75,45%,6/14,43%,1.0,1.0
-Abnormal appendicular skeleton morphology [HP:0011844],93/93,100%,34/34,100%,1.0,1.0
-Aplasia/hypoplasia of the extremities [HP:0009815],78/78,100%,22/22,100%,1.0,1.0
-Aplasia/hypoplasia involving the skeleton [HP:0009115],80/80,100%,23/23,100%,1.0,1.0
-Aplasia/hypoplasia involving bones of the upper limbs [HP:0006496],78/78,100%,22/22,100%,1.0,1.0
-Aplasia/hypoplasia involving bones of the extremities [HP:0045060],78/78,100%,22/22,100%,1.0,1.0
-Abnormal long bone morphology [HP:0011314],50/50,100%,13/13,100%,1.0,1.0
-Aplasia/Hypoplasia of fingers [HP:0006265],44/44,100%,19/19,100%,1.0,1.0
-Aplasia/hypoplasia involving bones of the hand [HP:0005927],44/44,100%,19/19,100%,1.0,1.0
-Abnormal finger morphology [HP:0001167],64/64,100%,31/31,100%,1.0,1.0
-Abnormal digit morphology [HP:0011297],67/67,100%,33/33,100%,1.0,1.0
-Abnormal thumb morphology [HP:0001172],58/58,100%,31/31,100%,1.0,1.0
+Ventricular septal defect [HP:0001629],19/19,100%,42/71,59%,0.005806240832840839,0.00024192670136836828
+Absent thumb [HP:0009777],14/31,45%,18/100,18%,0.04456405819223913,0.0037136715160199273
+Secundum atrial septal defect [HP:0001684],4/22,18%,23/55,42%,0.4065940176561687,0.06544319142266644
+Triphalangeal thumb [HP:0001199],13/32,41%,23/99,23%,0.4065940176561687,0.06932119159387057
+Muscular ventricular septal defect [HP:0011623],6/25,24%,8/84,10%,0.4065940176561687,0.08470708701170182
+Short thumb [HP:0009778],8/30,27%,25/69,36%,1.0,0.4870099714553749
+Absent radius [HP:0003974],6/25,24%,9/43,21%,1.0,0.7703831604944444
+Atrial septal defect [HP:0001631],20/20,100%,63/65,97%,1.0,1.0
+Abnormal atrial septum morphology [HP:0011994],20/20,100%,64/64,100%,1.0,1.0
+Abnormal cardiac septum morphology [HP:0001671],28/28,100%,89/89,100%,1.0,1.0
+Abnormal cardiac atrium morphology [HP:0005120],20/20,100%,64/64,100%,1.0,1.0
+Hypoplasia of the radius [HP:0002984],6/14,43%,34/75,45%,1.0,1.0
+Abnormal appendicular skeleton morphology [HP:0011844],34/34,100%,93/93,100%,1.0,1.0
+Aplasia/hypoplasia of the extremities [HP:0009815],22/22,100%,78/78,100%,1.0,1.0
+Aplasia/hypoplasia involving the skeleton [HP:0009115],23/23,100%,80/80,100%,1.0,1.0
+Aplasia/hypoplasia involving bones of the upper limbs [HP:0006496],22/22,100%,78/78,100%,1.0,1.0
+Aplasia/hypoplasia involving bones of the extremities [HP:0045060],22/22,100%,78/78,100%,1.0,1.0
+Abnormal long bone morphology [HP:0011314],13/13,100%,50/50,100%,1.0,1.0
+Abnormal hand morphology [HP:0005922],20/20,100%,75/75,100%,1.0,1.0
+Abnormal thumb morphology [HP:0001172],31/31,100%,58/58,100%,1.0,1.0
+Abnormal finger morphology [HP:0001167],31/31,100%,64/64,100%,1.0,1.0
+Abnormal digit morphology [HP:0011297],33/33,100%,67/67,100%,1.0,1.0
+Aplasia/Hypoplasia of fingers [HP:0006265],19/19,100%,44/44,100%,1.0,1.0
+Aplasia/hypoplasia involving bones of the hand [HP:0005927],19/19,100%,44/44,100%,1.0,1.0
diff --git a/docs/user-guide/analyses/survival.rst b/docs/user-guide/analyses/survival.rst
index 109ec55d..a0e86c1d 100644
--- a/docs/user-guide/analyses/survival.rst
+++ b/docs/user-guide/analyses/survival.rst
@@ -48,8 +48,8 @@ One allele of exon 3 vs. one allele of elsewhere.
 
 >>> from gpsea.analysis.predicate.genotype import VariantPredicates
 >>> is_in_exon3 = VariantPredicates.exon(exon=3, tx_id=tx_id)
->>> is_in_exon3.get_question()
-'variant affects exon 3 on NM_003361.4'
+>>> is_in_exon3.description
+'overlaps with exon 3 of NM_003361.4'
 
 Monoallelic predicate to compare one allele of *UMOD* exon 3 variant
 versus one allele of other *UMOD* variant:
@@ -60,8 +60,8 @@ versus one allele of other *UMOD* variant:
 ...     b_predicate=~is_in_exon3,
 ...     a_label="Exon 3", b_label="Other",
 ... )
->>> gt_predicate.display_question()
-'Allele group: Exon 3, Other'
+>>> gt_predicate.group_labels
+('Exon 3', 'Other')
 
 
 Survival endpoint
@@ -84,8 +84,8 @@ to compute the time until an individual develops end stage renal disease:
 >>> from gpsea.analysis.temporal.endpoint import hpo_onset
 >>> term_id = "HP:0003774"  # Stage 5 chronic kidney disease
 >>> endpoint = hpo_onset(hpo=hpo, term_id=term_id)
->>> endpoint.display_question()
-'Compute time until postnatal onset of Stage 5 chronic kidney disease'
+>>> endpoint.description
+'Compute time until onset of Stage 5 chronic kidney disease'
 
 
 Statistical test
@@ -128,8 +128,35 @@ Kaplan-Meier curves
 
 We can plot Kaplan-Meier curves:
 
-TODO: implement!
+>>> from gpsea.model import Age
+>>> import matplotlib as mpl
+>>> import matplotlib.pyplot as plt
+>>> fig, ax = plt.subplots(figsize=(6, 4), dpi=120)
+>>> result.plot_kaplan_meier_curves(
+...     ax=ax,
+... )
+>>> _ = ax.xaxis.set(
+...     # Show X axis in years ...
+...     major_formatter=mpl.ticker.FuncFormatter(lambda x, pos: f"{x / Age.DAYS_IN_YEAR:.0f}"),  
+...     # ... with a tick for every decade
+...     major_locator=mpl.ticker.MultipleLocator(10 * Age.DAYS_IN_YEAR),
+... )
+>>> _ = ax.set(
+...     xlabel=endpoint.name + " [years]",
+...     ylabel="Empirical survival",
+... )
+>>> _ = ax.grid(axis="y")
+
+.. image:: /img/umod_km_curves.png
+   :alt: UMOD Kaplan-Meier curves
+   :align: center
+   :width: 600px
+
+.. doctest:: survival
+   :hide:
 
+   >>> fig.savefig('docs/img/umod_km_curves.png')  # doctest: +SKIP
+   
 
 Raw data
 --------
@@ -138,7 +165,7 @@ The `result` includes the survival values for all cohort members:
 
 >>> survivals = result.data.sort_index()
 >>> survivals.head()  # doctest: +NORMALIZE_WHITESPACE
-                          genotype    survival
+                          genotype    phenotype
 patient_id                                                                        
 AII.1[PMID_22034507_AII_1]       0    Survival(value=18262.5, is_censored=True)
 AII.2[PMID_22034507_AII_2]       0    None
@@ -148,7 +175,7 @@ AIII.4[PMID_22034507_AIII_4]     0    Survival(value=19723.5, is_censored=False)
 
 Each line corresponeds to an individual and the dataframe is indexed by the individual's identifier/label.
 The `genotype` column contains the genotype group code,
-and `survival` column includes a :class:`~gpsea.analysis.temporal.Survival` value
+and `phenotype` column includes a :class:`~gpsea.analysis.temporal.Survival` value
 or `None` if computing the survival was impossible (see :func:`~gpsea.analysis.temporal.endpoint.hpo_onset` for details).
 The `Survival` reports the number of days until attaining the endpoint,
 here defined as end stage renal disease (`is_censored=False`),
diff --git a/docs/user-guide/exploratory.rst b/docs/user-guide/exploratory.rst
index 568e7cb0..13429e61 100644
--- a/docs/user-guide/exploratory.rst
+++ b/docs/user-guide/exploratory.rst
@@ -111,7 +111,7 @@ with an overview about the HPO terms, variants, diseases, and variant effects th
 .. doctest:: exploratory
     :hide:
 
-    >>> report.write('docs/user-guide/reports/tbx5_cohort_INFO.html')  # doctest: +SKIP
+    >>> report.write('docs/user-guide/reports/tbx5_cohort_info.html')  # doctest: +SKIP
 
 
 Distribution of variants across protein domains
diff --git a/docs/user-guide/predicates/allele_count.rst b/docs/user-guide/predicates/allele_count.rst
new file mode 100644
index 00000000..8c90ca51
--- /dev/null
+++ b/docs/user-guide/predicates/allele_count.rst
@@ -0,0 +1,81 @@
+.. _allele-count:
+
+=====================
+Group by allele count
+=====================
+
+Sometimes, we may want to compare individuals with different allele counts of a single variant category.
+For instance, we may want to compare the survival of individuals harboring a mutation in *EGFR* (:math:`AC = 1`)
+with those with no such mutation (:math:`AC = 0`). 
+Alternatively, in some genes, heterozygous mutations (:math:`AC = 1`) and biallelic mutations (:math:`AC = 2`)
+can lead to different diseases.
+
+The allele count analysis differs from the :ref:`variant-category` analysis.
+The allele count analysis partitions the individuals based on different allele counts of one genotype category,
+while the variant category analysis partitions the individuals based on a fixed allele count of different genotype categories.
+
+The allele count analysis can be done with :func:`~gpsea.analysis.predicate.genotype.allele_count` predicate.
+
+********
+Examples
+********
+
+Compare the individuals with *EGFR* mutation
+============================================
+
+First, let's create a :class:`~gpsea.analysis.predicate.genotype.VariantPredicate` to include
+any *EGFR* variant:
+
+>>> from gpsea.analysis.predicate.genotype import VariantPredicates
+>>> affects_egfr = VariantPredicates.gene(symbol="EGFR")
+>>> affects_egfr.description
+'affects EGFR'
+
+Next, we create allele count predicate to partition the individuals
+based on presence of zero or one *EGFR* mutation allele:
+
+>>> from gpsea.analysis.predicate.genotype import allele_count
+>>> gt_predicate = allele_count(
+...     counts=({0,}, {1,}),
+...     target=affects_egfr,
+... )
+>>> gt_predicate.group_labels
+('0', '1')
+
+We create the predicate with two arguments.
+The `counts` argument takes a tuple of two sets, to partition the individuals
+based on zero (``{0,}``) or one (``{1,}``) target variant allele.
+The `target` takes a :class:`~gpsea.analysis.predicate.genotype.VariantPredicate`
+for defining the target variants.
+
+We can use the `gt_predicate` to partition a cohort along the genotype axis,
+e.g. to compare the patient survivals in a `survival analysis <survival>`.
+
+
+Compare the individuals with monoallelic and biallelic mutations
+================================================================
+
+Let's prepare a predicate for grouping individuals based on one or two alleles of a target mutation.
+
+For this example, the target mutation is any mutation that affects *LMNA*
+
+>>> from gpsea.analysis.predicate.genotype import VariantPredicates
+>>> affects_lmna = VariantPredicates.gene(symbol="LMNA")
+>>> affects_lmna.description
+'affects LMNA'
+
+and we will compare the individuals with one allele with those with two alleles:
+
+>>> gt_predicate = allele_count(
+...     counts=({1,}, {2,}),
+...     target=affects_lmna,
+... )
+>>> gt_predicate.group_labels
+('1', '2')
+
+
+The predicate will partition the individuals into two groups:
+those with one *LMNA* variant allele and those with two *LMNA* variant alleles.
+The individual with other allele counts (e.g. `0` or `3`) will be excluded
+from the analysis.
+
diff --git a/docs/user-guide/predicates/allele_count_predicates.rst b/docs/user-guide/predicates/allele_count_predicates.rst
deleted file mode 100644
index b358c481..00000000
--- a/docs/user-guide/predicates/allele_count_predicates.rst
+++ /dev/null
@@ -1,200 +0,0 @@
-.. _allele-count-predicates:
-
-=======================
-Allele count predicates
-=======================
-
-Sometimes we want to analyze the individuals who have the same allele count of the variants of interest.
-Most of the time, the allele count is `1` to analyze individuals with an autosomal dominant disease,
-or `2` for the individuals with a disease segregating with the autosomal recessive mode of inheritance.
-
-GPSEA provides :func:`~gpsea.analysis.predicate.genotype.monoallelic_predicate`
-and :func:`~gpsea.analysis.predicate.genotype.biallelic_predicate` to investigate
-the individuals with `1` or `2` alleles of the variants of interest.
-
-Both predicates take two variant predicates (:class:`~gpsea.analysis.predicate.genotype.VariantPredicate`)
-that are used to determine the allele count of the target variants. 
-
-
-.. _monoallelic-predicate:
-
-*********************
-Monoallelic predicate
-*********************
-
-Monoallelic predicate compares individuals who have *one* allele of a variant of interest.
-The predicate requires two variant predicates `A` and `B` for choosing
-the variants of interest (e.g. variants overlapping with a specific protein domain).
-The allele counts :math:`A_{AC}` and :math:`B_{AC}` for the variants of interest
-are computed and the individual is assigned into a category
-based on the following table:
-
-.. table:: Monoallelic predicate genotype categories
-
-    +-------------------+-------------------+-----------+
-    | :math:`A_{AC}`    | :math:`B_{AC}`    | Category  |
-    +===================+===================+===========+
-    | 1                 | 0                 | A         |
-    +-------------------+-------------------+-----------+
-    | 0                 | 1                 | B         |
-    +-------------------+-------------------+-----------+
-    | other             | other             | ``None``  |
-    +-------------------+-------------------+-----------+
-
-The individuals with different allele counts
-(e.g. :math:`A_{AC} = 0` and :math:`B_{AC} = 2`)
-are assigned into the ``None`` group and, thus, omitted from the analysis.
-
-
-Example
-=======
-
-Let's create a predicate to categorize the individuals
-to those having one missense allele or to those having
-one frameshift allele with respect to fictional transcript ``NM_1234.5``.
-
-We start by creating the variant predicates for missense (`A`)
-and frameshift (`B`) variants:
-
->>> from gpsea.model import VariantEffect
->>> from gpsea.analysis.predicate.genotype import VariantPredicates
->>> tx_id = "NM_1234.5"
->>> is_missense = VariantPredicates.variant_effect(effect=VariantEffect.MISSENSE_VARIANT, tx_id=tx_id)
->>> is_frameshift = VariantPredicates.variant_effect(effect=VariantEffect.FRAMESHIFT_VARIANT, tx_id=tx_id)
-
-Monoallelic predicate lets us customize the category names.
-Let's use `Missense` and `Frameshift` instead of the defaults `A` and `B`:
-
->>> a_label = "Missense"
->>> b_label = "Frameshift"
-
-Now we have all we need to create the monoallelic predicate:
-
->>> from gpsea.analysis.predicate.genotype import monoallelic_predicate
->>> gt_predicate = monoallelic_predicate(
-...     a_predicate=is_missense,
-...     b_predicate=is_frameshift,
-...     a_label=a_label, b_label=b_label,
-... )
->>> gt_predicate.display_question()
-'Allele group: Missense, Frameshift'
-
-`gt_predicate` can be used in any GPSEA analysis, to compare the individuals who harbor
-one allele of a missense variant with the individuals with one allele that leads to a frameshift.
-
-
-.. _biallelic-predicate:
-
-*******************
-Biallelic predicate
-*******************
-
-Biallelic predicate compares the individuals with *two* alleles of the variants of interest.
-The functionality is very similar to that of monoallelic predicate, with two differences.
-
-
-Categories
-==========
-
-A biallelic locus can be present in one of three genotypes.
-For example, using the predicates `A` and `B` for the missense vs. frameshift comparison as in the monoallelic case,
-we will assign the individuals into one of the following genotype categories:
-
-.. table:: Biallelic predicate genotype categories
-
-    +-------------------+-------------------+-----------+----------------+
-    | :math:`A_{AC}`    | :math:`B_{AC}`    | Category  | Category index |
-    +===================+===================+===========+================+
-    | 2                 | 0                 | A/A       | 0              |
-    +-------------------+-------------------+-----------+----------------+
-    | 1                 | 1                 | A/B       | 1              |
-    +-------------------+-------------------+-----------+----------------+
-    | 0                 | 2                 | B/B       | 2              |
-    +-------------------+-------------------+-----------+----------------+
-    | other             | other             | ``None``  |                |
-    +-------------------+-------------------+-----------+----------------+
-
-The individuals with a different allele count combination
-(e.g. :math:`A_{AC} = 1` and :math:`B_{AC} = 2`)
-are assigned into the ``None`` group and will be, thus, omitted from the analysis.
-
-    
-Partitions
-==========
-
-Sometimes we are interested in lumping several genotype categories into a group and then comparing the groups.
-For instance, we may want to compare phenotype of the individuals with *at least one* frameshift allele
-with those with *no* frameshift allele.
-
-The `partitions` option dictates form of the genotype categories.
-The option needs a set of sets of category indices (see table above).
-The set is a partition of a set with the following properties:
-
-* no subset is empty
-* the union of the subsets include all group indices
-* the intersection of any subsets is empty
-
-as in `partitions of a set <https://en.wikipedia.org/wiki/Partition_of_a_set>`_.
-
-
-Examples
-========
-
-Let `A` and `B` correspond to the variant predicates that select *MISSENSE* and *FRAMESHIFT* variants,
-and let's reuse the variant predicates ``is_missense`` and ``is_frameshift`` from the previous section.
-
-
-Compare missense vs. frameshift
--------------------------------
-
-We compare missense and frameshift variants in the context of an autosomal recessive disease,
-and we will use the same allele group names as before - `Missense` and `Frameshift`.
-
-
->>> from gpsea.analysis.predicate.genotype import biallelic_predicate
->>> gt_predicate = biallelic_predicate(
-...     a_predicate=is_missense,
-...     b_predicate=is_frameshift,
-...     a_label=a_label, b_label=b_label,
-... )
->>> gt_predicate.display_question()
-'Allele group: Missense/Missense, Missense/Frameshift, Frameshift/Frameshift'
-
-The predicate will assign the individuals into one of three genotype groups:
-
-* `Missense/Missense` - individual with two missense alleles
-* `Missense/Frameshift` - individual with one missense allele and one frameshift allele
-* `Frameshift/Frameshift` - individual with two frameshift alleles
-
-.. note::
-
-    This corresponds to the partitions::
-
-        partitions = ({0,}, {1,}, {2,})
-
-
-Compare missense vs. at least one frameshift allele
----------------------------------------------------
-
-Here we compare individuals with at least one frameshift allele with those with only missense variants.
-We can achieve this by providing the `partitions` option:
-
->>> partitions = ({0,}, {1, 2})
-
-The partitions includes subsets of the category indices to lump together.
-With reference to the *Biallelic predicate genotype categories* table,
-the first subset represents the individuals with `Missense/Missense` genotype
-and the second subset represents those with either `Missense/Frameshift` or `Frameshift/Frameshift`.
-
-We provide `partitions` as an extra argument
-to the :func:`~gpsea.analysis.predicate.genotype.biallelic_predicate` function:
-
->>> gt_predicate = biallelic_predicate(
-...     a_predicate=is_missense,
-...     b_predicate=is_frameshift,
-...     a_label=a_label, b_label=b_label,
-...     partitions=partitions,
-... )
->>> gt_predicate.display_question()
-'Allele group: Missense/Missense, Missense/Frameshift OR Frameshift/Frameshift'
-
diff --git a/docs/user-guide/predicates/devries.rst b/docs/user-guide/predicates/devries.rst
index 1062dc5a..37989c86 100644
--- a/docs/user-guide/predicates/devries.rst
+++ b/docs/user-guide/predicates/devries.rst
@@ -172,5 +172,7 @@ and use it to create :class:`~gpsea.analysis.pscore.DeVriesPhenotypeScorer`
 
 >>> from gpsea.analysis.pscore import DeVriesPhenotypeScorer
 >>> pheno_scorer = DeVriesPhenotypeScorer(hpo)
+>>> pheno_scorer.description
+'A phenotypic severity score for individuals with intellectual disability'
 
 which we can use as a phenotype scorer.
diff --git a/docs/user-guide/predicates/diagnosis.rst b/docs/user-guide/predicates/diagnosis.rst
new file mode 100644
index 00000000..8acf8062
--- /dev/null
+++ b/docs/user-guide/predicates/diagnosis.rst
@@ -0,0 +1,24 @@
+.. _group-by-diagnosis:
+
+==================
+Group by diagnosis
+==================
+
+We can easily compare individuals diagnosed with different diseases.
+:func:`~gpsea.analysis.predicate.genotype.diagnosis_predicate` groups the individuals
+based on the disease diagnoses present in the individuals.
+
+>>> from gpsea.analysis.predicate.genotype import diagnosis_predicate
+>>> gt_predicate = diagnosis_predicate(
+...     diagnoses=('OMIM:154700', 'OMIM:129600'),
+...     labels=('Marfan syndrome', 'Ectopia lentis, familial'),
+... )
+>>> gt_predicate.group_labels
+('OMIM:154700', 'OMIM:129600')
+
+The predicate takes two or more disease identifiers (`diagnoses`) as well as their names (`labels`),
+and it assigns the individuals based on their diagnoses.
+
+Note, the assignment must be unambiguous; any individual labeled with two or more target diagnoses
+(e.g. an individual diagnosed with both *Marfan syndrome* and *Ectopia lentis, familial* in the example above)
+will be *omitted* from the analysis.
diff --git a/docs/user-guide/predicates/diagnosis_predicate.rst b/docs/user-guide/predicates/diagnosis_predicate.rst
deleted file mode 100644
index 386af20a..00000000
--- a/docs/user-guide/predicates/diagnosis_predicate.rst
+++ /dev/null
@@ -1,21 +0,0 @@
-.. _diagnosis-predicate:
-
-========================
-Partition by a diagnosis
-========================
-
-It is also possible to bin the individuals based on a diagnosis.
-The :func:`~gpsea.analysis.predicate.genotype.diagnosis_predicate` 
-prepares a genotype predicate for assigning an individual into a diagnosis group:
-
->>> from gpsea.analysis.predicate.genotype import diagnosis_predicate
->>> gt_predicate = diagnosis_predicate(
-...     diagnoses=('OMIM:154700', 'OMIM:129600'),
-...     labels=('Marfan syndrome', 'Ectopia lentis, familial'),
-... )
->>> gt_predicate.display_question()
-'What disease was diagnosed: OMIM:154700, OMIM:129600'
-
-Note, an individual must match only one diagnosis group. Any individuals labeled with two or more diagnoses
-(e.g. an individual with both *Marfan syndrome* and *Ectopia lentis, familial*)
-will be automatically omitted from the analysis.
\ No newline at end of file
diff --git a/docs/user-guide/predicates/genotype_predicates.rst b/docs/user-guide/predicates/genotype_predicates.rst
index 1ba42bb8..30c17aac 100644
--- a/docs/user-guide/predicates/genotype_predicates.rst
+++ b/docs/user-guide/predicates/genotype_predicates.rst
@@ -23,11 +23,8 @@ the :class:`~gpsea.analysis.predicate.genotype.VariantPredicate` is all about.
   :caption: Contents:
 
   variant_predicates
-  allele_count_predicates
-  mode_of_inheritance_predicate
-  male_female_predicate
-  diagnosis_predicate
-  groups_predicate
-
-
+  variant_category
+  allele_count
+  sex
+  diagnosis
 
diff --git a/docs/user-guide/predicates/groups_predicate.rst b/docs/user-guide/predicates/groups_predicate.rst
deleted file mode 100644
index a6142897..00000000
--- a/docs/user-guide/predicates/groups_predicate.rst
+++ /dev/null
@@ -1,41 +0,0 @@
-.. _groups-predicate:
-
-================
-Groups Predicate
-================
-
-
-
-Sometimes, all we want is to compare if there is a difference between individuals
-who include one or more alleles of variant `X` vs. individuals with variants `Y`,
-vs. individuals with variants `Z`, where `X`, `Y` and `Z` are variant predicates.
-We can do this with a *groups* predicate.
-
-The :func:`~gpsea.analysis.predicate.genotype.groups_predicate`
-takes *n* variant predicates and *n* group labels, and it will assign the patients
-into the respective groups if one or more matching allele is found.
-However, only one predicate is allowed to return a non-zero allele count.
-Otherwise, the patient is assigned with ``None`` and excluded from the analysis.
-
-Example
--------
-
-Here we show how to build a :class:`~gpsea.analysis.predicate.genotype.GenotypePolyPredicate`
-for testing if the individual has at least one missense vs. frameshift vs. synonymous variant.
-
->>> from gpsea.model import VariantEffect
->>> from gpsea.analysis.predicate.genotype import VariantPredicates, groups_predicate
->>> tx_id = 'NM_1234.5'
->>> gt_predicate = groups_predicate(
-...     predicates=(
-...         VariantPredicates.variant_effect(VariantEffect.MISSENSE_VARIANT, tx_id),
-...         VariantPredicates.variant_effect(VariantEffect.FRAMESHIFT_VARIANT, tx_id),
-...         VariantPredicates.variant_effect(VariantEffect.SYNONYMOUS_VARIANT, tx_id),
-...     ),
-...     group_names=('Missense', 'Frameshift', 'Synonymous'),
-... )
->>> gt_predicate.display_question()
-'Genotype group: Missense, Frameshift, Synonymous'
-
-
-
diff --git a/docs/user-guide/predicates/hpo_predicate.rst b/docs/user-guide/predicates/hpo_predicate.rst
index 5da37b1f..b259feff 100644
--- a/docs/user-guide/predicates/hpo_predicate.rst
+++ b/docs/user-guide/predicates/hpo_predicate.rst
@@ -1,3 +1,4 @@
+
 .. _hpo-predicate:
 
 
@@ -35,8 +36,12 @@ and now we can set up a predicate to test for presence of *Abnormal lens morphol
 ...     hpo=hpo,
 ...     query=query,
 ... )
->>> pheno_predicate.display_question()
-'Is Abnormal lens morphology present in the patient: Yes, No'
+>>> pheno_predicate.name
+'HPO Predicate'
+>>> pheno_predicate.description
+'Test for presence of Abnormal lens morphology [HP:0000517]'
+>>> pheno_predicate.group_labels
+('Yes', 'No')
 
 
 
diff --git a/docs/user-guide/predicates/male_female_predicate.rst b/docs/user-guide/predicates/male_female_predicate.rst
deleted file mode 100644
index 3bed237e..00000000
--- a/docs/user-guide/predicates/male_female_predicate.rst
+++ /dev/null
@@ -1,21 +0,0 @@
-.. _male-female-predicate:
-
-Partition by the sex of the individual
-======================================
-
-It is easy to investigate the phenotypic differences between females and males.
-The :func:`~gpsea.analysis.predicate.genotype.sex_predicate` provides a predicate
-for partitioning based on the sex of the individual:
-
->>> from gpsea.analysis.predicate.genotype import sex_predicate
->>> gt_predicate = sex_predicate()
->>> gt_predicate.display_question()
-'Sex of the individual: FEMALE, MALE'
-
-The individuals with :class:`~gpsea.model.Sex.UNKNOWN_SEX` will be omitted from the analysis.
-
-Note that we have implemented this predicate as a genotype predicate, because it is used in 
-place of other genotype predicates. Currently, it is not possible to compare the distribution of genotypes across sexes.
-
-
-
diff --git a/docs/user-guide/predicates/mode_of_inheritance_predicate.rst b/docs/user-guide/predicates/mode_of_inheritance_predicate.rst
deleted file mode 100644
index 49a3c487..00000000
--- a/docs/user-guide/predicates/mode_of_inheritance_predicate.rst
+++ /dev/null
@@ -1,175 +0,0 @@
-.. _mode-of-inheritance-predicate:
-
-==============================
-Mode of Inheritance Predicates
-==============================
-
-There are five basic modes of inheritance for single-gene diseases: autosomal dominant, 
-autosomal recessive, X-linked dominant, X-linked recessive, and mitochondrial 
-(See `Understanding Genetics, Appendix B <https://www.ncbi.nlm.nih.gov/books/NBK132145/>`_).
-
-GPSEA provides Mode of Inheritance predicates that categorize individuals
-based on the number of variant alleles. Clinical judgment should be used to choose the MoI
-for the cohort analysis.
-
-By default, MoI predicates use *all* variants recorded in the individual.
-However, a :class:`~gpsea.analysis.predicate.genotype.VariantPredicate`
-can be provided to select the variants of interest. For instance, missense variants
-or the variants that overlap with a specific protein domain.
-
-
-.. _autosomal-dominant-predicate:
-
-****************************
-Autosomal dominant predicate
-****************************
-
-Autosomal dominant predicate counts the alleles of variants of interest
-to assign an individual into one of the following categories:
-
-.. table:: Autosomal dominant predicate categories
-
-    +------------------+----------------+
-    |   Allele count   |  Category      |
-    +==================+================+
-    |   0              |  `No allele`   |
-    +------------------+----------------+
-    |   1              |  `Monoallelic` |
-    +------------------+----------------+
-    |   :math:`\ge 2`  |  ``None``      |
-    +------------------+----------------+
-
-Examples
-========
-
-Use all variants
-----------------
-
-We can create the predicate with the :func:`~gpsea.analysis.predicate.genotype.autosomal_dominant` function:
-
->>> from gpsea.analysis.predicate.genotype import autosomal_dominant
->>> gt_predicate = autosomal_dominant()
->>> gt_predicate.display_question()
-'What is the genotype group: No allele, Monoallelic'
-
-
-Use a variant subset
---------------------
-
-To only consider a subset of variants, a :class:`~gpsea.analysis.predicate.genotype.VariantPredicate` can be provided,
-to, for example, only include the missense variants on transcript ``NM_1234.5``:
-
->>> from gpsea.model import VariantEffect
->>> from gpsea.analysis.predicate.genotype import VariantPredicates
->>> is_missense = VariantPredicates.variant_effect(effect=VariantEffect.MISSENSE_VARIANT, tx_id="NM_1234.5")
->>> is_missense.get_question()
-'MISSENSE_VARIANT on NM_1234.5'
-
-
-.. _autosomal-recessive-predicate:
-
-*****************************
-Autosomal recessive predicate
-*****************************
-
-The autosomal recessive predicate uses the allele count to assign
-an individual into one of the genotype categories:
-
-.. table:: Autosomal recessive predicate categories
-
-    +------------------+-------------------+----------------+
-    |   Allele count   |  Category         | Category index |
-    +==================+===================+================+
-    |   0              |  `No allele`      | 0              |
-    +------------------+-------------------+----------------+
-    |   1              |  `Monoallelic`    | 1              |
-    +------------------+-------------------+----------------+
-    |   2              |  `Biallelic`      | 2              |
-    +------------------+-------------------+----------------+
-    |   :math:`\ge 3`  |  ``None``         |                |
-    +------------------+-------------------+----------------+
-
-.. note::
-
-    `Biallelic` includes both homozygous and compound heterozygous genotypes.
-
-
-Partitions
-==========
-
-Sometimes we are interested in lumping several genotype categories into a group or and then comparing the groups.
-For instance, to compare phenotype of the individuals with *at least one* frameshift allele
-with those with *no* frameshift allele. Alternatively, we may only want to analyze a subset of the genotype categories,
-such as `Monoallelic` vs. `Biallelic`.
-
-The `partitions` option of the :func:`~gpsea.analysis.predicate.genotype.autosomal_recessive` function
-lets us do this.
-The option needs a set of sets of category indices (see table above).
-The set is a partition of a set with the following properties:
-
-* no subset is empty
-* the intersection of any subsets is empty
-
-These rules are very much alike the properties of the `partitions of a set <https://en.wikipedia.org/wiki/Partition_of_a_set>`_,
-with the exception that adherence to *the union of the subsets include all group indices* rule is *NOT* required.
-
-
-Examples
-========
-
-
-Use all variants
-----------------
-
-We create the predicate
-with the :func:`~gpsea.analysis.predicate.genotype.autosomal_recessive` function:
-
->>> from gpsea.analysis.predicate.genotype import autosomal_recessive
->>> gt_predicate = autosomal_recessive()
->>> gt_predicate.display_question()
-'What is the genotype group: No allele, Monoallelic, Biallelic'
-
-
-Use a variant subset
---------------------
-
-Same as in the autosomal dominant version,
-we can use a :class:`~gpsea.analysis.predicate.genotype.VariantPredicate`
-to only count the alleles of the variants of interest, such as the missense variants
-of a fictional transcript ``NM_1234.5``:
-
->>> from gpsea.model import VariantEffect
->>> from gpsea.analysis.predicate.genotype import VariantPredicates
->>> is_missense = VariantPredicates.variant_effect(effect=VariantEffect.MISSENSE_VARIANT, tx_id="NM_1234.5")
->>> is_missense.get_question()
-'MISSENSE_VARIANT on NM_1234.5'
-
-and then use it to create the autosomal recessive predicate:
-
->>> gt_predicate = autosomal_recessive(is_missense)
->>> gt_predicate.display_question()
-'What is the genotype group: No allele, Monoallelic, Biallelic'
-
-This predicate will assign the individuals into one of the listed genotype categories
-based on the allele counts of the missense variants.
-
-
-Compare `Monoallelic` vs. `Biallelic`
--------------------------------------
-
-We can provide ``partitions`` to only compare the heterozygotes with those carrying
-biallelic alt mutations (homozygous alternate or compound heterozygous):
-
-We consult the *Autosomal recessive predicate categories* table for the category indices
-and we create the genotype group partitions:
-
->>> # `1` for `Monoallelic` and `2` for `Biallelic`
->>> partitions = ({1,}, {2,})
-
-which we use to create the autosomal recessive predicate:
-
->>> gt_predicate = autosomal_recessive(
-...     partitions=partitions,    
-... )
->>> gt_predicate.display_question()
-'What is the genotype group: Monoallelic, Biallelic'
diff --git a/docs/user-guide/predicates/sex.rst b/docs/user-guide/predicates/sex.rst
new file mode 100644
index 00000000..2640a320
--- /dev/null
+++ b/docs/user-guide/predicates/sex.rst
@@ -0,0 +1,20 @@
+.. _group-by-sex:
+
+============
+Group by sex
+============
+
+It is easy to investigate the differences between males and females.
+The :func:`~gpsea.analysis.predicate.genotype.sex_predicate` partitions
+the individuals based on their :class:`~gpsea.model.Sex`:
+
+>>> from gpsea.analysis.predicate.genotype import sex_predicate
+>>> gt_predicate = sex_predicate()
+>>> gt_predicate.group_labels
+('FEMALE', 'MALE')
+
+The individuals with :class:`~gpsea.model.Sex.UNKNOWN_SEX` will be omitted from the analysis.
+
+Note that we implemented this predicate as a genotype predicate.
+Currently, it is not possible to compare the distribution of genotypes across sexes.
+
diff --git a/docs/user-guide/predicates/variant_category.rst b/docs/user-guide/predicates/variant_category.rst
new file mode 100644
index 00000000..c8ff6a72
--- /dev/null
+++ b/docs/user-guide/predicates/variant_category.rst
@@ -0,0 +1,199 @@
+`.. _variant-category:
+
+=========================
+Group by variant category
+=========================
+
+Sometimes we want to compare the individuals who have the same allele count (`AC`) of variant categories :math:`A` and :math:`B`.
+For example, in the context of an autosomal dominant disease,
+we may want to compare the individuals with :math:`AC_{A}=1` (where :math:`A` is e.g. a predicted loss-of-function mutation)
+with those harboring :math:`AC_{B}=1` (where :math:`B` is e.g. a missense mutation).
+Similarly, in an autosomal recessive disease, we may be interested in comparing the individuals
+with :math:`AC_{A} \ge 1` with those with :math:`AC_{A} = 0`.
+In both analyses, we compare two variant categories :math:`A` and :math:`B`
+which are described by a :class:`~gpsea.analysis.predicate.genotype.VariantPredicate`
+(see :ref:`variant_predicates` section),
+while ensuring the allele count sum of both variant categories is :math:`k`.
+
+:math:`k = \sum_{i \in \{A, B\}} AC_{i}`
+
+GPSEA provides two predicates:
+
+
+.. table::
+
+    +-----------+-----------------------+------------------------------------------------------------------+
+    | :math:`k` | Name                  | Function                                                         |
+    +===========+=======================+==================================================================+
+    | 1         | Monoallelic predicate | :func:`~gpsea.analysis.predicate.genotype.monoallelic_predicate` |
+    +-----------+-----------------------+------------------------------------------------------------------+
+    | 2         | Biallelic predicate   | :func:`~gpsea.analysis.predicate.genotype.biallelic_predicate`   |
+    +-----------+-----------------------+------------------------------------------------------------------+
+
+
+.. _monoallelic-predicate:
+
+*********************
+Monoallelic predicate
+*********************
+
+Monoallelic predicate compares individuals who have *one* allele of a variants of interest.
+The predicate needs two variant predicates `A` and `B`
+to compute the allele counts :math:`AC_{A}` and :math:`AC_{B}`,
+in order to assign an individual into one of the following genotype groups:
+
+.. table:: Monoallelic predicate genotype groups
+
+    +-----------------+-------------------+-------------------+
+    | Genotype group  | :math:`AC_{A}`    | :math:`AC_{B}`    |
+    +=================+===================+===================+
+    | A               | 1                 | 0                 |
+    +-----------------+-------------------+-------------------+
+    | B               | 0                 | 1                 |
+    +-----------------+-------------------+-------------------+
+    | ``None``        | other             | other             |
+    +-----------------+-------------------+-------------------+
+
+The individuals with :math:`\sum_{i \in \{A, B\}} AC_{i} \neq 1`
+are omitted from the analysis.
+
+
+Example
+=======
+
+Let's create a predicate to categorize the individuals
+to those having one missense allele or to those having
+one frameshift allele with respect to fictional transcript ``NM_1234.5``.
+
+>>> tx_id = "NM_1234.5"
+
+We start by creating the variant predicates for missense (`A`)
+and frameshift (`B`) variants:
+
+>>> from gpsea.model import VariantEffect
+>>> from gpsea.analysis.predicate.genotype import VariantPredicates
+>>> is_missense = VariantPredicates.variant_effect(effect=VariantEffect.MISSENSE_VARIANT, tx_id=tx_id)
+>>> is_frameshift = VariantPredicates.variant_effect(effect=VariantEffect.FRAMESHIFT_VARIANT, tx_id=tx_id)
+
+Monoallelic predicate lets us customize the category names.
+Let's use `Missense` and `Frameshift` instead of the defaults:
+
+>>> a_label = "Missense"
+>>> b_label = "Frameshift"
+
+Now we have all we need to create the predicate:
+
+>>> from gpsea.analysis.predicate.genotype import monoallelic_predicate
+>>> gt_predicate = monoallelic_predicate(
+...     a_predicate=is_missense,
+...     b_predicate=is_frameshift,
+...     a_label=a_label, b_label=b_label,
+... )
+>>> gt_predicate.group_labels
+('Missense', 'Frameshift')
+
+
+
+.. _biallelic-predicate:
+
+*******************
+Biallelic predicate
+*******************
+
+Biallelic predicate compares the individuals with *two* alleles of the variants of interest.
+The functionality is very similar to that of monoallelic predicate, with two differences.
+
+
+Categories
+==========
+
+Biallelic locus can be present in one of three genotypes, allowing an individual
+to be assigned into one of the three genotype groups:
+
+.. _biallelic-predicate-gt-groups:
+
+.. table:: Biallelic predicate genotype groups
+
+    +-------+----------------+-------------------+-------------------+
+    | Index | Genotype group | :math:`AC_{A}`    | :math:`AC_{B}`    |
+    +=======+================+===================+===================+
+    | 0     | A/A            | 2                 | 0                 |
+    +-------+----------------+-------------------+-------------------+
+    | 1     | A/B            | 1                 | 1                 |
+    +-------+----------------+-------------------+-------------------+
+    | 2     | B/B            | 0                 | 2                 |
+    +-------+----------------+-------------------+-------------------+
+    |       | ``None``       | other             | other             |
+    +-------+----------------+-------------------+-------------------+
+
+Note that :math:`\sum_{i \in \{A, B\}} AC_{i} = 2` and the individuals
+with a different allele count sum are omitted from the analysis.
+
+Example
+-------
+
+Let `A` and `B` correspond to *MISSENSE* and *FRAMESHIFT* variants,
+and let's reuse the variant predicates ``is_missense`` and ``is_frameshift`` from the previous section,
+to compare missense and frameshift variants in the context of an autosomal recessive disease.
+
+>>> from gpsea.analysis.predicate.genotype import biallelic_predicate
+>>> gt_predicate = biallelic_predicate(
+...     a_predicate=is_missense,
+...     b_predicate=is_frameshift,
+...     a_label="Missense", b_label="Frameshift",
+... )
+>>> gt_predicate.group_labels
+('Missense/Missense', 'Missense/Frameshift', 'Frameshift/Frameshift')
+
+
+The predicate will assign the individuals into one of three genotype groups:
+
+* `Missense/Missense` - two missense alleles
+* `Missense/Frameshift` - one missense allele and one frameshift allele
+* `Frameshift/Frameshift` - two frameshift alleles
+
+    
+Partitions
+==========
+
+Sometimes we are interested in lumping several genotype groups into a partition
+and then comparing the partitions.
+For instance, in the context of an autosomal recessive disease,
+we may want to compare individuals with two "mild" mutations with the individuals
+with at least one "severe" mutation.
+This comparison can be implemented using the `partitions` option.
+
+We define a partition as a set of one or more genotype group indices
+(see :ref:`biallelic-predicate-gt-groups` table),
+and we must provide at least two such partitions to the `partitions` option.
+
+
+Example
+-------
+
+Let `A` and `B` correspond to *MISSENSE* and *FRAMESHIFT* variant
+Here we compare the individuals with two missense alleles with the individuals with one frameshift and one missense alleles,
+or with two frameshift alelles.
+
+The partition for the two missense alleles will include the genotype group `0`,
+and the one or more frameshift alleles partition corresponds to the genotype groups `{1, 2}` (see :ref:`biallelic-predicate-gt-groups` table).
+The complete partitions are defined as:
+
+>>> partitions = ({0,}, {1, 2})
+
+We provide `partitions`
+to the :func:`~gpsea.analysis.predicate.genotype.biallelic_predicate` function:
+
+>>> gt_predicate = biallelic_predicate(
+...     a_predicate=is_missense,
+...     b_predicate=is_frameshift,
+...     a_label="Missense", b_label="Frameshift",
+...     partitions=partitions,
+... )
+>>> gt_predicate.group_labels
+('Missense/Missense', 'Missense/Frameshift OR Frameshift/Frameshift')
+
+Now `gt_predicate` assigns an individual into one of the two categories:
+
+* `Missense/Missense`
+* `Missense/Frameshift` OR `Frameshift/Frameshift`
diff --git a/docs/user-guide/predicates/variant_predicates.rst b/docs/user-guide/predicates/variant_predicates.rst
index 1053fbbb..c8a97a74 100644
--- a/docs/user-guide/predicates/variant_predicates.rst
+++ b/docs/user-guide/predicates/variant_predicates.rst
@@ -21,46 +21,35 @@ The variant predicate can leverage multiple primary data:
 | Protein data           | variant is located in a region encoding a protein domain, protein feature type                  |
 +------------------------+-------------------------------------------------------------------------------------------------+
 
-which demands a considerable amount of flexibility for creating the predicate.
 
 As a rule of thumb, the predicates for testing basic conditions are available off the shelf,
 and they can be used as building block for testing for more complex conditions,
 such as testing if the variant is "a missense or synonymous variant located in exon 6 of transcript `NM_013275.6`".
 
-Let's demonstrate this on few examples.
-We will load a cohort of 19 subjects with variants in *RERE*,
-leading to `Holt-Oram syndrome MIM:142900 <https://omim.org/entry/142900>`_.
-The the clinical signs and symptoms of the subjects were encoded into HPO terms
-along with the pathogenic *RERE* variant.
+Let's demonstrate the variant predicate usage on a few examples.
 
-Let's load the phenopackets, as previously described in greater detail the :ref:`input-data` section.
-Briefly, we first load HPO:
 
->>> import hpotk
->>> store = hpotk.configure_ontology_store()
->>> hpo = store.load_minimal_hpo(release='v2024-07-01')
+Load cohort
+-----------
 
-then, we configure the cohort creator:
+For the purpose of this example, we will load a :class:`~gpsea.model.Cohort`
+of 19 individuals with mutations in *RERE* leading to Holt-Oram syndrome.
+The cohort was prepared from phenopackets as described in :ref:`create-cohort-from-phenopackets` section,
+and then serialized as
+a `JSON file <https://github.com/monarch-initiative/gpsea/tree/main/docs/cohort-data/RERE.0.1.20.json>`_
+following the instructions in :ref:`cohort-persistence` section.
 
->>> from gpsea.preprocessing import configure_caching_cohort_creator
->>> cohort_creator = configure_caching_cohort_creator(hpo)
+.. 
+   Prepare the JSON file by running the tests in `tests/tests/test_generate_doc_cohorts.py`.
 
-which we use to create a :class:`~gpsea.model.Cohort` from a bunch of phenopackets
-from the release `0.1.18` of `Phenopacket Store <https://github.com/monarch-initiative/phenopacket-store>`_.
-We will load 19 individuals with mutations in *RERE* gene:
-
->>> from ppktstore.registry import configure_phenopacket_registry
->>> registry = configure_phenopacket_registry()
->>> with registry.open_phenopacket_store(release='0.1.18') as ps:
-...     phenopackets = tuple(ps.iter_cohort_phenopackets('RERE'))
->>> len(phenopackets)
+>>> import json
+>>> from gpsea.io import GpseaJSONDecoder
+>>> fpath_cohort_json = 'docs/cohort-data/RERE.0.1.20.json'
+>>> with open(fpath_cohort_json) as fh:
+...     cohort = json.load(fh, cls=GpseaJSONDecoder)
+>>> len(cohort)
 19
 
-and we will convert the phenopacket into a :class:`~gpsea.model.Cohort`:
-
->>> from gpsea.preprocessing import load_phenopackets
->>> cohort, _ = load_phenopackets(phenopackets, cohort_creator)  # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
-Individuals Processed: ...
 
 To demonstrate the predicate API, we will use the variant ``1_8358231_8358231_T_C`` that corresponds 
 to a pathogenic variant `VCV000522858.5 <https://www.ncbi.nlm.nih.gov/clinvar/variation/522858/>`_ 
@@ -128,7 +117,7 @@ such as testing if the variant is a *"chromosomal deletion" or a deletion which
 >>> from gpsea.model import VariantClass
 >>> chromosomal_deletion = "SO:1000029"
 >>> predicate = VariantPredicates.structural_type(chromosomal_deletion) | (VariantPredicates.variant_class(VariantClass.DEL) & VariantPredicates.change_length("<=", -50))
->>> predicate.get_question()
+>>> predicate.description
 '(structural type is SO:1000029 OR (variant class is DEL AND change length <= -50))'
 
 
@@ -157,7 +146,7 @@ the underlying predicate and to invert its test result.
 This is how we can use the predicate inversion to build the predicate for non-frameshift deletions:
 
 >>> non_frameshift_del = ~VariantPredicates.variant_effect(VariantEffect.FRAMESHIFT_VARIANT, tx_id=rere_mane_tx_id) & VariantPredicates.variant_class(VariantClass.DEL)
->>> non_frameshift_del.get_question()
+>>> non_frameshift_del.description
 '(NOT FRAMESHIFT_VARIANT on NM_001042681.2 AND variant class is DEL)'
 
 Note the presence of a tilde ``~`` before the variant effect predicate and resulting ``NOT`` in the predicate question.
diff --git a/docs/user-guide/reports/tbx5_cohort_info.html b/docs/user-guide/reports/tbx5_cohort_info.html
index 762d3abe..8905975c 100644
--- a/docs/user-guide/reports/tbx5_cohort_info.html
+++ b/docs/user-guide/reports/tbx5_cohort_info.html
@@ -64,7 +64,13 @@
 
 <body>
   <h1>GPSEA cohort analysis</h1>
-  <p>Successfully loaded 156 individuals.</p>
+  <p>Successfully loaded 156 individuals. 46 were recorded as male, 56 as female,
+      and 54 as unknown sex. 156 were reported to be alive at the time of last
+      encounter, and 0 were deceased. 1 had disease onset information
+      and 51 had information about the age at last encounter. In the cohort,
+      there were a total of 117 HPO terms used for annotation.
+    
+  </p>
   
   <p>No errors encountered.</p>
   
@@ -72,7 +78,7 @@ <h1>GPSEA cohort analysis</h1>
     <table>
     <caption style="color: black;">
         <h3>Top 10 HPO Terms</h3>
-        A total of 116 HPO terms were used to annotated the cohort.
+        A total of 117 HPO terms were used to annotated the cohort.
     </caption>
         <tbody>
             <tr class="strng">
@@ -106,14 +112,14 @@ <h3>Top 10 HPO Terms</h3>
                 </tr>
             
             <tr>
-                <td class="lft">Short thumb</td>
-                <td>HP:0009778</td>
+                <td class="lft">Absent thumb</td>
+                <td>HP:0009777</td>
                 <td>32</td>
                 </tr>
             
             <tr>
-                <td class="lft">Absent thumb</td>
-                <td>HP:0009777</td>
+                <td class="lft">Short thumb</td>
+                <td>HP:0009778</td>
                 <td>32</td>
                 </tr>
             
@@ -144,14 +150,16 @@ <h3>Top 10 HPO Terms</h3>
                 </tbody>
     </table>
 
+  
+
     <table>
     <caption style="color: black;">
         <h3>Top 10 Variants</h3>
-        Variants are shown according to NM_181486.4. A total of 156 unique variants were identified in the cohort.
+        Variants are shown according to NM_181486.4. A total of 53 unique variants were identified in the cohort.
     </caption>
             <tbody>
                 <tr class="strng">
-                    <th>Count</th>
+                    <th>Seen in <em>n</em> individuals</th>
                     <th class="lft">Variant key</th>
                     <th>Variant Name</th>
                     <th>Protein Variant</th>
@@ -190,14 +198,6 @@ <h3>Top 10 Variants</h3>
                     <td>STOP_GAINED</td>
                 </tr>
                 
-                <tr>
-                    <td>5</td>
-                    <td class="lft">12_114399514_114399514_A_C</td>
-                    <td>c.361T>G</td>
-                    <td>p.Trp121Gly</td>
-                    <td>MISSENSE_VARIANT, SPLICE_REGION_VARIANT</td>
-                </tr>
-                
                 <tr>
                     <td>5</td>
                     <td class="lft">12_114398682_114398682_C_CG</td>
@@ -206,6 +206,14 @@ <h3>Top 10 Variants</h3>
                     <td>FRAMESHIFT_VARIANT</td>
                 </tr>
                 
+                <tr>
+                    <td>5</td>
+                    <td class="lft">12_114399514_114399514_A_C</td>
+                    <td>c.361T>G</td>
+                    <td>p.Trp121Gly</td>
+                    <td>MISSENSE_VARIANT, SPLICE_REGION_VARIANT</td>
+                </tr>
+                
                 <tr>
                     <td>5</td>
                     <td class="lft">12_114403792_114403792_C_CG</td>
@@ -216,10 +224,10 @@ <h3>Top 10 Variants</h3>
                 
                 <tr>
                     <td>4</td>
-                    <td class="lft">12_114385474_114385474_A_G</td>
-                    <td>c.755+2T>C</td>
-                    <td>None</td>
-                    <td>SPLICE_DONOR_VARIANT</td>
+                    <td class="lft">12_114385522_114385522_G_A</td>
+                    <td>c.709C>T</td>
+                    <td>p.Arg237Trp</td>
+                    <td>MISSENSE_VARIANT</td>
                 </tr>
                 
                 <tr>
@@ -268,61 +276,73 @@ <h3>Variant categories for NM_181486.4</h3>
             <tr class="strng">
                 <th class="lft">Variant effect</th>
                 <th>Annotation Count</th>
+                <th>Percent</th>
               </tr>
               
               <tr>
                 <td class="lft">MISSENSE_VARIANT</td>
                 <td>85</td>
+               <td>50%</td>
                 </tr>
             
               <tr>
                 <td class="lft">FRAMESHIFT_VARIANT</td>
                 <td>38</td>
+               <td>22%</td>
                 </tr>
             
               <tr>
-                <td class="lft">SPLICE_REGION_VARIANT</td>
-                <td>10</td>
+                <td class="lft">STOP_GAINED</td>
+                <td>19</td>
+               <td>11%</td>
                 </tr>
             
               <tr>
-                <td class="lft">INFRAME_DELETION</td>
-                <td>1</td>
+                <td class="lft">SPLICE_REGION_VARIANT</td>
+                <td>10</td>
+               <td>6%</td>
                 </tr>
             
               <tr>
-                <td class="lft">INFRAME_INSERTION</td>
-                <td>2</td>
+                <td class="lft">SPLICE_DONOR_VARIANT</td>
+                <td>7</td>
+               <td>4%</td>
                 </tr>
             
               <tr>
-                <td class="lft">STOP_RETAINED_VARIANT</td>
+                <td class="lft">SPLICE_ACCEPTOR_VARIANT</td>
                 <td>2</td>
+               <td>1%</td>
                 </tr>
             
               <tr>
-                <td class="lft">STOP_GAINED</td>
-                <td>19</td>
+                <td class="lft">SPLICE_DONOR_5TH_BASE_VARIANT</td>
+                <td>2</td>
+               <td>1%</td>
                 </tr>
             
               <tr>
-                <td class="lft">SPLICE_DONOR_VARIANT</td>
-                <td>7</td>
+                <td class="lft">INTRON_VARIANT</td>
+                <td>2</td>
+               <td>1%</td>
                 </tr>
             
               <tr>
-                <td class="lft">SPLICE_ACCEPTOR_VARIANT</td>
+                <td class="lft">INFRAME_INSERTION</td>
                 <td>2</td>
+               <td>1%</td>
                 </tr>
             
               <tr>
-                <td class="lft">SPLICE_DONOR_5TH_BASE_VARIANT</td>
+                <td class="lft">STOP_RETAINED_VARIANT</td>
                 <td>2</td>
+               <td>1%</td>
                 </tr>
             
               <tr>
-                <td class="lft">INTRON_VARIANT</td>
-                <td>2</td>
+                <td class="lft">INFRAME_DELETION</td>
+                <td>1</td>
+               <td>1%</td>
                 </tr>
             
            </tbody>
diff --git a/pyproject.toml b/pyproject.toml
index fe12aa6a..68fda037 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,7 @@ classifiers = [
 ]
 dependencies = [
     "hpo-toolkit>=0.3.0",
+    "stairval>=0.2.0",
     "Jinja2>=3.1.4,<4.0.0",
     "protobuf>=3.15.0",
     "pandas>=2.0.0,<3.0.0",
diff --git a/src/gpsea/__init__.py b/src/gpsea/__init__.py
index 717151dc..cddd77e8 100644
--- a/src/gpsea/__init__.py
+++ b/src/gpsea/__init__.py
@@ -2,4 +2,4 @@
 GPSEA is a library for analyzing genotype-phenotype correlations in cohorts of rare disease patients.
 """
 
-__version__ = "0.6.0"
+__version__ = "0.7.0"
diff --git a/src/gpsea/analysis/__init__.py b/src/gpsea/analysis/__init__.py
index e69de29b..78a6de66 100644
--- a/src/gpsea/analysis/__init__.py
+++ b/src/gpsea/analysis/__init__.py
@@ -0,0 +1,5 @@
+from ._base import AnalysisResult, MonoPhenotypeAnalysisResult, MultiPhenotypeAnalysisResult
+
+__all__ = [
+    "AnalysisResult", "MonoPhenotypeAnalysisResult", "MultiPhenotypeAnalysisResult",
+]
diff --git a/src/gpsea/analysis/_base.py b/src/gpsea/analysis/_base.py
new file mode 100644
index 00000000..c132f795
--- /dev/null
+++ b/src/gpsea/analysis/_base.py
@@ -0,0 +1,350 @@
+import abc
+import math
+import os
+import typing
+
+import pandas as pd
+
+from .predicate.phenotype import PhenotypePolyPredicate, P
+from .predicate.genotype import GenotypePolyPredicate
+from ._partition import Partitioning
+
+
+class Statistic(metaclass=abc.ABCMeta):
+    """
+    Mixin for classes that are used to compute a nominal p value for a genotype-phenotype association.
+    """
+    
+    def __init__(
+        self,
+        name: str,
+    ):
+        self._name = name
+
+    @property
+    def name(self) -> str:
+        """
+        Get the name of the statistic (e.g. `Fisher Exact Test`, `Logrank test`).
+        """
+        return self._name
+    
+    def __eq__(self, value: object) -> bool:
+        if isinstance(value, Statistic):
+            return self._name == value._name
+        return NotImplemented
+    
+    def __hash__(self) -> int:
+        return hash((self._name,))
+
+
+class AnalysisResult(metaclass=abc.ABCMeta):
+    """
+    `AnalysisResult` includes the common parts of results of all analyses.
+    """
+
+    def __init__(
+        self,
+        gt_predicate: GenotypePolyPredicate,
+        statistic: Statistic,
+    ):
+        assert isinstance(gt_predicate, GenotypePolyPredicate)
+        self._gt_predicate = gt_predicate
+
+        assert isinstance(statistic, Statistic)
+        self._statistic = statistic
+
+    @property
+    def gt_predicate(self) -> GenotypePolyPredicate:
+        """
+        Get the genotype predicate used in the survival analysis that produced this result.
+        """
+        return self._gt_predicate
+    
+    @property
+    def statistic(self) -> Statistic:
+        """
+        Get the statistic which computed the (nominal) p values for this result.
+        """
+        return self._statistic
+
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, AnalysisResult) \
+            and self._gt_predicate == value._gt_predicate \
+            and self._statistic == value._statistic
+    
+    def __hash__(self) -> int:
+        return hash((
+            self._gt_predicate,
+            self._statistic,
+        ))
+
+
+class MultiPhenotypeAnalysisResult(typing.Generic[P], AnalysisResult):
+    """
+    `MultiPhenotypeAnalysisResult` reports the outcome of an analysis
+    that tested the association of genotype with two or more phenotypes.
+    """
+    
+    def __init__(
+        self,
+        gt_predicate: GenotypePolyPredicate,
+        pheno_predicates: typing.Iterable[PhenotypePolyPredicate[P]],
+        statistic: Statistic,
+        n_usable: typing.Sequence[int],
+        all_counts: typing.Sequence[pd.DataFrame],
+        pvals: typing.Sequence[float],
+        corrected_pvals: typing.Optional[typing.Sequence[float]],
+        mtc_correction: typing.Optional[str]
+    ):
+        super().__init__(
+            gt_predicate=gt_predicate,
+            statistic=statistic,
+        )
+
+        self._pheno_predicates = tuple(pheno_predicates)
+
+        self._n_usable = tuple(n_usable)
+        self._all_counts = tuple(all_counts)
+
+        self._pvals = tuple(pvals)
+        self._corrected_pvals = (
+            None if corrected_pvals is None else tuple(corrected_pvals)
+        )
+        errors = self._check_sanity()
+        if errors:
+            raise ValueError(os.linesep.join(errors))
+
+        if mtc_correction is not None:
+            assert isinstance(mtc_correction, str)
+        self._mtc_correction = mtc_correction
+
+    def _check_sanity(self) -> typing.Sequence[str]:
+        errors = []
+        # All sequences must have the same lengths ...
+        for seq, name in (
+            (self._n_usable, 'n_usable'),
+            (self._all_counts, 'all_counts'),
+            (self._pvals, 'pvals'),
+        ):
+            if len(self._pheno_predicates) != len(seq):
+                errors.append(
+                    f"`len(pheno_predicates)` must be the same as `len({name})` but "
+                    f"{len(self._pheno_predicates)}!={len(seq)}"
+                )
+
+        # ... including the optional corrected p values
+        if self._corrected_pvals is not None and len(self._pheno_predicates) != len(self._corrected_pvals):
+            errors.append(
+                f"`len(pheno_predicates)` must be the same as `len(corrected_pvals)` but "
+                f"{len(self._pheno_predicates)}!={len(self._corrected_pvals)}"
+            )
+
+        if not isinstance(self._gt_predicate, GenotypePolyPredicate):
+            errors.append(
+                "`gt_predicate` must be an instance of `GenotypePolyPredicate`"
+            )
+        return errors
+
+    @property
+    def pheno_predicates(
+        self,
+    ) -> typing.Sequence[PhenotypePolyPredicate[P]]:
+        """
+        Get the phenotype predicates used in the analysis.
+        """
+        return self._pheno_predicates
+
+    @property
+    def phenotypes(self) -> typing.Sequence[P]:
+        """
+        Get the phenotypes that were tested for association with genotype in the analysis.
+        """
+        return tuple(p.phenotype for p in self._pheno_predicates)
+
+    @property
+    def n_usable(self) -> typing.Sequence[int]:
+        """
+        Get a sequence of numbers of patients where the phenotype was assessable,
+        and are, thus, usable for genotype-phenotype correlation analysis.
+        """
+        return self._n_usable
+
+    @property
+    def all_counts(self) -> typing.Sequence[pd.DataFrame]:
+        """
+        Get a :class:`~pandas.DataFrame` sequence where each `DataFrame` includes the counts of patients
+        in genotype and phenotype groups.
+
+        An example for a genotype predicate that bins into two categories (`Yes` and `No`) based on presence
+        of a missense variant in transcript `NM_123456.7`, and phenotype predicate that checks
+        presence/absence of `HP:0001166` (a phenotype term)::
+
+                       Has MISSENSE_VARIANT in NM_123456.7
+                       No       Yes
+            Present
+            Yes        1        13
+            No         7        5
+
+        The rows correspond to the phenotype categories, and the columns represent the genotype categories.
+        """
+        return self._all_counts
+
+    @property
+    def pvals(self) -> typing.Sequence[float]:
+        """
+        Get a sequence of nominal p values for each tested phenotype.
+        The sequence includes a `NaN` value for each phenotype that was *not* tested.
+        """
+        return self._pvals
+
+    @property
+    def corrected_pvals(self) -> typing.Optional[typing.Sequence[float]]:
+        """
+        Get a sequence with p values for each tested phenotype after multiple testing correction
+        or `None` if the correction was not applied.
+        The sequence includes a `NaN` value for each phenotype that was *not* tested.
+        """
+        return self._corrected_pvals
+
+    @property
+    def total_tests(self) -> int:
+        """
+        Get total count of genotype-phenotype associations that were tested in this analysis.
+        """
+        return sum(1 for pval in self.pvals if not math.isnan(pval))
+
+    @property
+    def mtc_correction(self) -> typing.Optional[str]:
+        """
+        Get name/code of the used multiple testing correction
+        (e.g. `fdr_bh` for Benjamini-Hochberg) or `None` if no correction was applied.
+        """
+        return self._mtc_correction
+
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, MultiPhenotypeAnalysisResult) \
+            and super(AnalysisResult, self).__eq__(value) \
+            and self._pheno_predicates == value._pheno_predicates \
+            and self._n_usable == value._n_usable \
+            and self._all_counts == value._all_counts \
+            and self._pvals == value._pvals \
+            and self._corrected_pvals == value._corrected_pvals \
+            and self._mtc_correction == value._mtc_correction
+    
+    def __hash__(self) -> int:
+        return hash((
+            super(AnalysisResult, self).__hash__(),
+            self._pheno_predicates,
+            self._n_usable,
+            self._all_counts,
+            self._pvals,
+            self._corrected_pvals,
+            self._mtc_correction,
+        ))
+
+
+class MonoPhenotypeAnalysisResult(AnalysisResult, metaclass=abc.ABCMeta):
+    """
+    `MonoPhenotypeAnalysisResult` reports the outcome of an analysis
+    that tested a single genotype-phenotype association.
+    """
+    
+    GT_COL = "genotype"
+    """
+    Name of column for storing genotype data.
+    """
+
+    PH_COL = "phenotype"
+    """
+    Name of column for storing phenotype data.
+    """
+
+    DATA_COLUMNS = (GT_COL, PH_COL)
+    """
+    The required columns of the `data` data frame.
+    """
+
+    def __init__(
+        self,
+        gt_predicate: GenotypePolyPredicate,
+        phenotype: Partitioning,
+        statistic: Statistic,
+        data: pd.DataFrame,
+        pval: float,
+    ):
+        super().__init__(gt_predicate, statistic)
+
+        assert isinstance(phenotype, Partitioning)
+        self._phenotype = phenotype
+
+        assert isinstance(data, pd.DataFrame) and all(
+            col in data.columns for col in MonoPhenotypeAnalysisResult.DATA_COLUMNS
+        )
+        self._data = data
+
+        if isinstance(pval, float) and math.isfinite(pval) and 0.0 <= pval <= 1.0:
+            self._pval = float(pval)
+        else:
+            raise ValueError(
+                f"`pval` must be a finite float in range [0, 1] but it was {pval}"
+            )
+        
+    @property
+    def phenotype(self) -> Partitioning:
+        """
+        Get the :class:`~gpsea.analysis.Partitioning` that produced the phenotype.
+        """
+        return self._phenotype
+
+    @property
+    def data(self) -> pd.DataFrame:
+        """
+        Get the data frame with genotype and phenotype values for each tested individual.
+
+        The index of the data frame contains the identifiers of the tested individuals,
+        and the values are stored in `genotype` and `phenotype` columns.
+        
+        The `genotype` column includes the genotype category ID
+        (:attr:`~gpsea.analysis.predicate.PatientCategory.cat_id`)
+        or `None` if the individual could not be assigned into a genotype group.
+        The `phenotype` contains the phenotype values, and the data type depends on the analysis.
+        
+        Here are some common phenotype data types:
+
+        * a phenotype score computed in :class:`~gpsea.analysis.pscore.PhenotypeScoreAnalysis` is a `float`
+        * survival computed in :class:`~gpsea.analysis.temporal.SurvivalAnalysis`
+          is of type :class:`~gpsea.analysis.temporal.Survival`
+        """
+        return self._data
+
+    def complete_records(self) -> pd.DataFrame:
+        """
+        Get the :attr:`~gpsea.analysis.temporal.MonoPhenotypeAnalysisResult.data` rows
+        where both `genotype` and `phenotype` columns are available (i.e. not `None` or `NaN`).
+        """
+        return self._data.loc[
+            self._data[MonoPhenotypeAnalysisResult.GT_COL].notna()
+            & self._data[MonoPhenotypeAnalysisResult.PH_COL].notna()
+        ]
+
+    @property
+    def pval(self) -> float:
+        """
+        Get the p value of the test.
+        """
+        return self._pval
+
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, MonoPhenotypeAnalysisResult) \
+            and super(AnalysisResult, self).__eq__(value) \
+            and self._phenotype == value._phenotype \
+            and self._pval == value._pval \
+            and self._data.equals(value._data)
+    
+    def __hash__(self) -> int:
+        return hash((
+            super(AnalysisResult, self).__hash__(),
+            self._phenotype,
+            self._pval,
+            self._data,
+        ))
diff --git a/src/gpsea/analysis/_partition.py b/src/gpsea/analysis/_partition.py
new file mode 100644
index 00000000..4aac168b
--- /dev/null
+++ b/src/gpsea/analysis/_partition.py
@@ -0,0 +1,69 @@
+import abc
+import os
+import typing
+
+from gpsea.model import Patient
+
+from ._util import Summarizable
+
+
+class Partitioning(Summarizable, metaclass=abc.ABCMeta):
+    """
+    `Partitioning` is a superclass of all classes that assign a group,
+    compute a score or survival for an individual.
+    """
+    
+    @property
+    @abc.abstractmethod
+    def name(self) -> str:
+        """
+        Get the name of the partitioning.
+        """
+        pass
+
+    @property
+    @abc.abstractmethod
+    def description(self) -> str:
+        """
+        Get a description of the partitioning.
+        """
+        pass
+
+    @property
+    @abc.abstractmethod
+    def variable_name(self) -> str:
+        """
+        Get a `str` with the name of the variable investigated by the partitioning.
+
+        For instance `Sex`, `Allele groups`, `HP:0001250`, `OMIM:256000`
+        """
+        pass
+
+    def summarize(
+        self,
+        out: typing.TextIO,
+    ):
+        """
+        Summarize the item while also considering `other` (default `None`).
+        """
+        out.write(self.name)
+        out.write(os.linesep)
+        out.write(self.description)
+        out.write(os.linesep)
+
+    @staticmethod
+    def _check_patient(patient: Patient):
+        """
+        Check if the `patient` meets the partitioning requirements.
+        """
+        if not isinstance(patient, Patient):
+            raise ValueError(f"patient must be type Patient but was type {type(patient)}")
+
+
+class ContinuousPartitioning(Partitioning, metaclass=abc.ABCMeta):
+    """
+    `ContinuousPartitioning` computes a score that is a real number.
+
+    The class is just a marker class at this time.
+    """
+    pass
diff --git a/src/gpsea/analysis/_util.py b/src/gpsea/analysis/_util.py
new file mode 100644
index 00000000..9f456403
--- /dev/null
+++ b/src/gpsea/analysis/_util.py
@@ -0,0 +1,29 @@
+import abc
+import io
+import typing
+
+
+class Summarizable(metaclass=abc.ABCMeta):
+    """
+    A mixin for entities that can summarize themselves into a provided IO handle.
+    """
+
+    @abc.abstractmethod
+    def summarize(
+        self,
+        out: typing.TextIO,
+    ):
+        """
+        Summarize the item into the provided IO handle.
+
+        :param out: an IO handle to write into.
+        """
+        pass
+
+    def summary(self) -> str:
+        """
+        Get the summary.
+        """
+        buf = io.StringIO()
+        self.summarize(buf)
+        return buf.getvalue()
diff --git a/src/gpsea/analysis/mtc_filter/_impl.py b/src/gpsea/analysis/mtc_filter/_impl.py
index f9415288..c43a89ad 100644
--- a/src/gpsea/analysis/mtc_filter/_impl.py
+++ b/src/gpsea/analysis/mtc_filter/_impl.py
@@ -7,6 +7,8 @@
 import hpotk
 import pandas as pd
 
+from hpotk.constants.hpo.base import PHENOTYPIC_ABNORMALITY
+
 from ..predicate.genotype import GenotypePolyPredicate
 from ..predicate.phenotype import PhenotypePolyPredicate, P
 
@@ -267,7 +269,7 @@ def default_filter(
         hpo: hpotk.MinimalOntology,
         term_frequency_threshold: float = 0.4,
         annotation_frequency_threshold: float = 0.4,
-        phenotypic_abnormality: hpotk.TermId = hpotk.constants.hpo.base.PHENOTYPIC_ABNORMALITY,
+        phenotypic_abnormality: hpotk.TermId = PHENOTYPIC_ABNORMALITY,
     ):
         """
         Args:
@@ -409,7 +411,7 @@ def filter(
                 continue
 
             if not self._hpo.graph.is_ancestor_of(
-                    hpotk.constants.hpo.base.PHENOTYPIC_ABNORMALITY, term_id
+                    PHENOTYPIC_ABNORMALITY, term_id
             ):
                 results[idx] = HpoMtcFilter.SKIPPING_NON_PHENOTYPE_TERM
                 continue
@@ -493,12 +495,15 @@ def filter(
     def possible_results(self) -> typing.Collection[PhenotypeMtcResult]:
         return (
             PhenotypeMtcFilter.OK,
-            HpoMtcFilter.SKIPPING_GENERAL_TERM,
-            HpoMtcFilter.SKIPPING_NON_PHENOTYPE_TERM,
-            self._below_frequency_threshold,
-            self._not_powered_for_2_by_2,
-            HpoMtcFilter.NO_GENOTYPE_HAS_MORE_THAN_ONE_HPO,
-            HpoMtcFilter.SKIPPING_SINCE_ONE_GENOTYPE_HAD_ZERO_OBSERVATIONS,
+            self._below_frequency_threshold,  # HMF01
+            HpoMtcFilter.NO_GENOTYPE_HAS_MORE_THAN_ONE_HPO,  # HMF02
+            HpoMtcFilter.SAME_COUNT_AS_THE_ONLY_CHILD,  # HMF03
+            HpoMtcFilter.SKIPPING_SINCE_ONE_GENOTYPE_HAD_ZERO_OBSERVATIONS,  # HMF05
+            self._not_powered_for_2_by_2,  # HMF06
+            self._not_powered_for_2_by_3,  # HMF06
+            HpoMtcFilter.SKIPPING_NON_PHENOTYPE_TERM,  # HMF07
+            HpoMtcFilter.SKIPPING_GENERAL_TERM,  # HMF08
+            self._below_annotation_frequency_threshold,  # HMF09
         )
 
     def filter_method_name(self) -> str:
diff --git a/src/gpsea/analysis/pcats/_impl.py b/src/gpsea/analysis/pcats/_impl.py
index 865e70a7..7c75706c 100644
--- a/src/gpsea/analysis/pcats/_impl.py
+++ b/src/gpsea/analysis/pcats/_impl.py
@@ -1,5 +1,4 @@
 import abc
-import math
 import os
 import typing
 
@@ -12,11 +11,13 @@
 from statsmodels.stats import multitest
 
 from gpsea.model import Patient
-from gpsea.analysis.pcats.stats import CountStatistic
+
 from ..predicate.genotype import GenotypePolyPredicate
 from ..predicate.phenotype import P, PhenotypePolyPredicate
 from ..mtc_filter import PhenotypeMtcFilter, PhenotypeMtcResult
 
+from .stats import CountStatistic
+from .._base import MultiPhenotypeAnalysisResult
 
 DEFAULT_MTC_PROCEDURE = 'fdr_bh'
 """
@@ -63,11 +64,11 @@ def apply_predicates_on_patients(
                 data=0,
                 index=pd.Index(
                     data=ph_predicate.get_categories(),
-                    name=ph_predicate.get_question_base(),
+                    name=ph_predicate.variable_name,
                 ),
                 columns=pd.Index(
                     data=gt_predicate.get_categories(),
-                    name=gt_predicate.get_question_base(),
+                    name=gt_predicate.variable_name,
                 ),
             )
 
@@ -92,71 +93,6 @@ def apply_predicates_on_patients(
     return n_usable_patients, counts
 
 
-class MultiPhenotypeAnalysisResult(typing.Generic[P], metaclass=abc.ABCMeta):
-    """
-    `MultiPhenotypeAnalysisResult` reports the outcome of :class:`MultiPhenotypeAnalysis`.
-
-    The result consists of several arrays with items
-    with the order determined by the phenotype order.
-    """
-
-    @property
-    @abc.abstractmethod
-    def n_usable(self) -> typing.Sequence[int]:
-        """
-        Get a sequence of numbers of patients where the phenotype was assessable,
-        and are, thus, usable for genotype-phenotype correlation analysis.
-        """
-        pass
-
-    @property
-    @abc.abstractmethod
-    def all_counts(self) -> typing.Sequence[pd.DataFrame]:
-        """
-        Get a :class:`~pandas.DataFrame` sequence where each `DataFrame` includes the counts of patients
-        in genotype and phenotype groups.
-
-        An example for a genotype predicate that bins into two categories (`Yes` and `No`) based on presence
-        of a missense variant in transcript `NM_123456.7`, and phenotype predicate that checks
-        presence/absence of `HP:0001166` (a phenotype term)::
-
-                       Has MISSENSE_VARIANT in NM_123456.7
-                       No       Yes
-            Present
-            Yes        1        13
-            No         7        5
-
-        The rows correspond to the phenotype categories, and the columns represent the genotype categories.
-        """
-        pass
-
-    @property
-    @abc.abstractmethod
-    def pvals(self) -> typing.Sequence[float]:
-        """
-        Get a sequence of nominal p values for each tested HPO term.
-        The sequence includes a `NaN` value for each input phenotype that was *not* tested.
-        """
-        pass
-
-    @property
-    @abc.abstractmethod
-    def corrected_pvals(self) -> typing.Optional[typing.Sequence[float]]:
-        """
-        Get a sequence with p values for each tested HPO term after multiple testing correction
-        or `None` if the correction was not applied.
-        The sequence includes a `NaN` value for each input phenotype that was *not* tested.
-        """
-        pass
-
-    @property
-    def total_tests(self) -> int:
-        """
-        Get total count of tests that were run for this analysis.
-        """
-        return sum(1 for pval in self.pvals if not math.isnan(pval))
-
-
 class MultiPhenotypeAnalysis(typing.Generic[P], metaclass=abc.ABCMeta):
 
     def __init__(
@@ -291,103 +227,6 @@ def _apply_mtc(
         return corrected_pvals
 
 
-class BaseMultiPhenotypeAnalysisResult(typing.Generic[P], MultiPhenotypeAnalysisResult[P]):
-
-    def __init__(
-        self,
-        pheno_predicates: typing.Iterable[PhenotypePolyPredicate[P]],
-        n_usable: typing.Sequence[int],
-        all_counts: typing.Sequence[pd.DataFrame],
-        pvals: typing.Sequence[float],
-        corrected_pvals: typing.Optional[typing.Sequence[float]],
-        gt_predicate: GenotypePolyPredicate,
-    ):
-        self._pheno_predicates = tuple(pheno_predicates)
-        self._n_usable = tuple(n_usable)
-        self._all_counts = tuple(all_counts)
-        self._pvals = tuple(pvals)
-        self._corrected_pvals = (
-            None if corrected_pvals is None else tuple(corrected_pvals)
-        )
-        self._gt_predicate = gt_predicate
-        errors = self._check_sanity()
-        if errors:
-            raise ValueError(os.linesep.join(errors))
-
-    def _check_sanity(self) -> typing.Sequence[str]:
-        errors = []
-        # All sequences must have the same lengths ...
-        for seq, name in (
-            (self._n_usable, 'n_usable'),
-            (self._all_counts, 'all_counts'),
-            (self._pvals, 'pvals'),
-        ):
-            if len(self._pheno_predicates) != len(seq):
-                errors.append(
-                    f"`len(pheno_predicates)` must be the same as `len({name})` but "
-                    f"{len(self._pheno_predicates)}!={len(seq)}"
-                )
-
-        # ... including the optional corrected p values
-        if self._corrected_pvals is not None and len(self._pheno_predicates) != len(self._corrected_pvals):
-            errors.append(
-                f"`len(pheno_predicates)` must be the same as `len(corrected_pvals)` but "
-                f"{len(self._pheno_predicates)}!={len(self._corrected_pvals)}"
-            )
-
-        if not isinstance(self._gt_predicate, GenotypePolyPredicate):
-            errors.append(
-                "`gt_predicate` must be an instance of `GenotypePolyPredicate`"
-            )
-        return errors
-
-    @property
-    def gt_predicate(self) -> GenotypePolyPredicate:
-        return self._gt_predicate
-
-    @property
-    def pheno_predicates(
-        self,
-    ) -> typing.Sequence[PhenotypePolyPredicate[P]]:
-        return self._pheno_predicates
-
-    @property
-    def phenotypes(self) -> typing.Sequence[P]:
-        return tuple(p.phenotype for p in self._pheno_predicates)
-
-    @property
-    def n_usable(self) -> typing.Sequence[int]:
-        return self._n_usable
-
-    @property
-    def all_counts(self) -> typing.Sequence[pd.DataFrame]:
-        return self._all_counts
-
-    @property
-    def pvals(self) -> typing.Sequence[float]:
-        return self._pvals
-
-    @property
-    def corrected_pvals(self) -> typing.Optional[typing.Sequence[float]]:
-        return self._corrected_pvals
-
-    def __eq__(self, other):
-        return isinstance(other, BaseMultiPhenotypeAnalysisResult) \
-            and self._pheno_predicates == other._pheno_predicates \
-            and self._n_usable == other._n_usable \
-            and self._all_counts == other._all_counts \
-            and self._pvals == other._pvals \
-            and self._corrected_pvals == other._corrected_pvals\
-            and self._gt_predicate == other._gt_predicate
-
-    def __hash__(self):
-        # NOTE: if a field is added, the hash method of the subclasses must be updated as well.
-        return hash((
-            self._pheno_predicates, self._n_usable, self._all_counts,
-            self._pvals, self._corrected_pvals, self._gt_predicate,
-        ))
-
-
 class DiseaseAnalysis(MultiPhenotypeAnalysis[hpotk.TermId]):
 
     def _compute_result(
@@ -416,17 +255,19 @@ def _compute_result(
         else:
             corrected_pvals = self._apply_mtc(pvals=pvals)
 
-        return BaseMultiPhenotypeAnalysisResult(
+        return MultiPhenotypeAnalysisResult(
+            gt_predicate=gt_predicate,
+            statistic=self._count_statistic,
+            mtc_correction=self._mtc_correction,
             pheno_predicates=pheno_predicates,
             n_usable=n_usable,
             all_counts=all_counts,
             pvals=pvals,
             corrected_pvals=corrected_pvals,
-            gt_predicate=gt_predicate,
         )
 
 
-class HpoTermAnalysisResult(BaseMultiPhenotypeAnalysisResult[hpotk.TermId]):
+class HpoTermAnalysisResult(MultiPhenotypeAnalysisResult[hpotk.TermId]):
     """
     `HpoTermAnalysisResult` includes the :class:`HpoTermAnalysis` results.
 
@@ -436,27 +277,30 @@ class HpoTermAnalysisResult(BaseMultiPhenotypeAnalysisResult[hpotk.TermId]):
 
     def __init__(
         self,
+        gt_predicate: GenotypePolyPredicate,
+        statistic: CountStatistic,
+        mtc_correction: typing.Optional[str],
         pheno_predicates: typing.Iterable[PhenotypePolyPredicate[hpotk.TermId]],
         n_usable: typing.Sequence[int],
         all_counts: typing.Sequence[pd.DataFrame],
         pvals: typing.Sequence[float],
         corrected_pvals: typing.Optional[typing.Sequence[float]],
-        gt_predicate: GenotypePolyPredicate,
         mtc_filter_name: str,
         mtc_filter_results: typing.Sequence[PhenotypeMtcResult],
-        mtc_name: typing.Optional[str],
+        
     ):
         super().__init__(
+            gt_predicate=gt_predicate,
             pheno_predicates=pheno_predicates,
+            statistic=statistic,
             n_usable=n_usable,
             all_counts=all_counts,
             pvals=pvals,
             corrected_pvals=corrected_pvals,
-            gt_predicate=gt_predicate,
+            mtc_correction=mtc_correction,
         )
         self._mtc_filter_name = mtc_filter_name
         self._mtc_filter_results = tuple(mtc_filter_results)
-        self._mtc_name = mtc_name
 
         errors = self._check_hpo_result_sanity()
         if errors:
@@ -485,26 +329,17 @@ def mtc_filter_results(self) -> typing.Sequence[PhenotypeMtcResult]:
         """
         return self._mtc_filter_results
 
-    @property
-    def mtc_name(self) -> typing.Optional[str]:
-        """
-        Get the name of the multiple testing correction (MTC) procedure (e.g. `bonferroni`, `fdr_bh`, ...)
-        or `None` if no MTC was performed.
-        """
-        return self._mtc_name
-
     def __eq__(self, other):
         return isinstance(other, HpoTermAnalysisResult) \
-            and BaseMultiPhenotypeAnalysisResult.__eq__(self, other) \
+            and super(MultiPhenotypeAnalysisResult, self).__eq__(other) \
             and self._mtc_filter_name == other._mtc_filter_name \
-            and self._mtc_filter_results == other._mtc_filter_results \
-            and self._mtc_name == other._mtc_name
+            and self._mtc_filter_results == other._mtc_filter_results
 
     def __hash__(self):
         return hash((
+            super(MultiPhenotypeAnalysisResult, self).__hash__(),
             self._pheno_predicates, self._n_usable, self._all_counts,
-            self._pvals, self._corrected_pvals, self._gt_predicate,
-            self._mtc_filter_name, self._mtc_filter_results, self._mtc_name,
+            self._mtc_filter_name, self._mtc_filter_results,
         ))
 
 
@@ -552,11 +387,12 @@ def _compute_result(
         )
 
         # 2 - Apply MTC filter and select p values to MTC
+        cohort_size = sum(1 for _ in cohort)
         mtc_filter_results = self._mtc_filter.filter(
             gt_predicate=gt_predicate,
             ph_predicates=pheno_predicates,
             counts=all_counts,
-            cohort_size=len(cohort),
+            cohort_size=cohort_size,
         )
 
         pvals = np.full(shape=(len(n_usable),), fill_value=np.nan)
@@ -579,15 +415,16 @@ def _compute_result(
                 corrected_pvals[mtc_mask] = self._apply_mtc(pvals=pvals[mtc_mask])
 
         return HpoTermAnalysisResult(
+            gt_predicate=gt_predicate,
+            statistic=self._count_statistic,
+            mtc_correction=self._mtc_correction,
             pheno_predicates=pheno_predicates,
             n_usable=n_usable,
             all_counts=all_counts,
             pvals=pvals,
             corrected_pvals=corrected_pvals,
-            gt_predicate=gt_predicate,
             mtc_filter_name=self._mtc_filter.filter_method_name(),
             mtc_filter_results=mtc_filter_results,
-            mtc_name=self._mtc_correction,
         )
 
 
diff --git a/src/gpsea/analysis/pcats/stats/_stats.py b/src/gpsea/analysis/pcats/stats/_stats.py
index 981176ee..187b03cf 100644
--- a/src/gpsea/analysis/pcats/stats/_stats.py
+++ b/src/gpsea/analysis/pcats/stats/_stats.py
@@ -10,8 +10,10 @@
 
 import scipy
 
+from ..._base import Statistic
 
-class CountStatistic(metaclass=abc.ABCMeta):
+
+class CountStatistic(Statistic, metaclass=abc.ABCMeta):
     """
     `CountStatistic` calculates a p value for a contingency table
     produced by a pair of discrete random variables.
@@ -45,6 +47,9 @@ class CountStatistic(metaclass=abc.ABCMeta):
     +------------------------+-------------------------+------------------+
     """
 
+    def __init__(self, name: str):
+        super().__init__(name)
+
     @property
     @abc.abstractmethod
     def supports_shape(
@@ -62,10 +67,16 @@ def compute_pval(
     ) -> float:
         pass
 
+    def __eq__(self, value: object) -> bool:
+        return super().__eq__(value)
+    
+    def __hash__(self) -> int:
+        return super().__hash__()
+
 
 class FisherExactTest(CountStatistic):
     """
-    `FisherExactTest` performs Fisher Exact Test on a `2x2` or `2x3` contingency table.
+    `FisherExactTest` performs Fisher's Exact Test on a `2x2` or `2x3` contingency table.
 
     The `2x2` version is a thin wrapper around Scipy :func:`~scipy.stats.fisher_exact` function,
     while the `2x3` variant is implemented in Python.
@@ -73,6 +84,9 @@ class FisherExactTest(CountStatistic):
     """
     
     def __init__(self):
+        super().__init__(
+            name="Fisher's Exact Test",
+        )
         self._shape = (2, (2, 3))
 
     @property
@@ -201,3 +215,9 @@ def _dfs(self, mat, pos, r_sum, c_sum, p_0, p):
                 else:
                     pos_new = (xx + 1, yy)
                 self._dfs(mat_new, pos_new, r_sum, c_sum, p_0, p)
+
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, FisherExactTest)
+    
+    def __hash__(self) -> int:
+        return 17
diff --git a/src/gpsea/analysis/predicate/__init__.py b/src/gpsea/analysis/predicate/__init__.py
index b4f3b28b..11c64a77 100644
--- a/src/gpsea/analysis/predicate/__init__.py
+++ b/src/gpsea/analysis/predicate/__init__.py
@@ -1,7 +1,5 @@
-from ._api import PatientCategory, PatientCategories, Categorization, C
 from ._api import PolyPredicate
 
 __all__ = [
-    'PatientCategory', 'PatientCategories', 'Categorization', 'C',
     'PolyPredicate',
 ]
diff --git a/src/gpsea/analysis/predicate/_api.py b/src/gpsea/analysis/predicate/_api.py
index 534eca18..1e528340 100644
--- a/src/gpsea/analysis/predicate/_api.py
+++ b/src/gpsea/analysis/predicate/_api.py
@@ -1,12 +1,16 @@
 import abc
+import os
 import typing
 
 from collections import Counter
 
+import hpotk
 import hpotk.util
 
 from gpsea.model import Patient
 
+from .._partition import Partitioning
+
 
 class PatientCategory:
     """
@@ -62,22 +66,6 @@ def __hash__(self) -> int:
         return hash((self.cat_id, self.name, self.description))
 
 
-class PatientCategories(metaclass=abc.ABCMeta):
-    """
-    A static utility class to serve common patient categories.
-    """
-
-    YES = PatientCategory(1, 'Yes', 'The patient belongs to the group.')
-    """
-    Category for a patient who *belongs* to the tested group.
-    """
-
-    NO = PatientCategory(0, 'No', 'The patient does not belong to the group.')
-    """
-    Category for a patient who does *not* belong to the tested group.
-    """
-
-
 class Categorization:
     """
     `Categorization` represents one of discrete group a :class:`~gpsea.model.Patient` can be assigned into.
@@ -129,10 +117,10 @@ def __str__(self):
 """
 
 
-class PolyPredicate(typing.Generic[C], metaclass=abc.ABCMeta):
+class PolyPredicate(typing.Generic[C], Partitioning, metaclass=abc.ABCMeta):
     """
-    `PolyPredicate` bins a :class:`~gpsea.model.Patient` into one of several discrete groups represented
-    by :class:`Categorization`.
+    `PolyPredicate` partitions a :class:`~gpsea.model.Patient` into one of several discrete groups
+    represented by a :class:`~gpsea.analysis.predicate.Categorization`.
 
     The groups must be *exclusive* - the patient can be binned into one and only one group,
     and *exhaustive* - the groups must cover all possible scenarios.
@@ -144,7 +132,7 @@ class PolyPredicate(typing.Generic[C], metaclass=abc.ABCMeta):
     @abc.abstractmethod
     def get_categorizations(self) -> typing.Sequence[C]:
         """
-        Get a sequence of all categories which the `PolyPredicate` can produce.
+        Get a sequence of all categories which the predicate can produce.
         """
         pass
 
@@ -154,11 +142,35 @@ def get_categories(self) -> typing.Iterator[PatientCategory]:
         """
         return (c.category for c in self.get_categorizations())
 
-    def get_category_names(self) -> typing.Iterator[str]:
+    @property
+    def group_labels(self) -> typing.Collection[str]:
+        """
+        Get a collection with names of the :class:`PatientCategory` items that the predicate can produce.
+        """
+        return tuple(cat.name for cat in self.get_categories())
+
+    def summarize_groups(self) -> str:
+        cat_names = ', '.join(self.group_labels)
+        return f"{self.variable_name}: {cat_names}"
+
+    def summarize(
+        self,
+        out: typing.TextIO,
+    ):
+        """
+        Summarize the predicate into the `out` handle.
+
+        The summary includes the name, summary, and the groups the predicate can assign individuals into.
         """
-        Get an iterator with names of the :class:`PatientCategory` items that the predicate can produce.
+        Partitioning.summarize(self, out)
+        out.write(self.summarize_groups())
+        out.write(os.linesep)
+
+    def n_categorizations(self) -> int:
+        """
+        Get the number of categorizations the predicate can produce.
         """
-        return (cat.name for cat in self.get_categories())
+        return len(self.get_categorizations())
 
     def get_category(
         self,
@@ -172,7 +184,7 @@ def get_category(
         """
         for ctg in self.get_categories():
             if ctg.cat_id == cat_id:
-                return ctg.name
+                return ctg
         raise ValueError(f'No category for {cat_id} was found')
 
     def get_category_name(
@@ -186,46 +198,7 @@ def get_category_name(
         :raises: ValueError if there is no such category was defined.
         """
         return self.get_category(cat_id).name
-
-    @abc.abstractmethod
-    def get_question_base(self) -> str:
-        """
-        Prepare a `str` with the question the predicate can answer.
-        """
-        pass
-
-    def display_question(self) -> str:
-        """
-        Prepare the question which the predicate can answer.
-
-        The question includes the question base and the category names
-        """
-        cat_names = ', '.join(self.get_category_names())
-        return f'{self.get_question_base()}: {cat_names}'
-
-    @abc.abstractmethod
-    def test(self, patient: Patient) -> typing.Optional[C]:
-        """
-        Assign a `patient` into a categorization.
-
-        Return `None` if the patient cannot be assigned into any meaningful category.
-        """
-        pass
-
-    def n_categorizations(self) -> int:
-        """
-        Get the number of categorizations the predicate can produce.
-        """
-        return len(self.get_categorizations())
-
-    @staticmethod
-    def _check_patient(patient: Patient):
-        """
-        Check if the `patient` meets the predicate requirements.
-        """
-        if not isinstance(patient, Patient):
-            raise ValueError(f"patient must be type Patient but was type {type(patient)}")
-
+    
     @staticmethod
     def _check_categorizations(
         categorizations: typing.Sequence[Categorization],
@@ -250,3 +223,12 @@ def _check_categorizations(
                 issues.append(f'`cat_id` {cat_id} is present {count}>1 times')
         
         return issues
+
+    @abc.abstractmethod
+    def test(self, patient: Patient) -> typing.Optional[C]:
+        """
+        Assign a `patient` into a categorization.
+
+        Return `None` if the patient cannot be assigned into any meaningful category.
+        """
+        pass
diff --git a/src/gpsea/analysis/predicate/genotype/__init__.py b/src/gpsea/analysis/predicate/genotype/__init__.py
index 59be80fe..c35fcaa5 100644
--- a/src/gpsea/analysis/predicate/genotype/__init__.py
+++ b/src/gpsea/analysis/predicate/genotype/__init__.py
@@ -1,16 +1,16 @@
 from ._api import GenotypePolyPredicate
 from ._api import VariantPredicate
 from ._counter import AlleleCounter
-from ._gt_predicates import groups_predicate, sex_predicate, diagnosis_predicate
-from ._gt_predicates import autosomal_dominant, autosomal_recessive
+from ._gt_predicates import sex_predicate, diagnosis_predicate
 from ._gt_predicates import monoallelic_predicate, biallelic_predicate
-from ._variant import VariantPredicates, ProteinPredicates
+from ._gt_predicates import allele_count
+from ._variant import VariantPredicates
 
 __all__ = [
     'GenotypePolyPredicate',
-    'groups_predicate', 'sex_predicate', 'diagnosis_predicate',
-    'autosomal_dominant', 'autosomal_recessive',
+    'sex_predicate', 'diagnosis_predicate',
     'monoallelic_predicate', 'biallelic_predicate',
+    'allele_count',
     'AlleleCounter', 'VariantPredicate',
-    'VariantPredicates', 'ProteinPredicates',
+    'VariantPredicates',
 ]
diff --git a/src/gpsea/analysis/predicate/genotype/_api.py b/src/gpsea/analysis/predicate/genotype/_api.py
index 769330ce..9d11072f 100644
--- a/src/gpsea/analysis/predicate/genotype/_api.py
+++ b/src/gpsea/analysis/predicate/genotype/_api.py
@@ -1,19 +1,21 @@
 import abc
+import warnings
 import typing
 
 from gpsea.model import Variant
 from .._api import PolyPredicate, Categorization
+from ..._partition import Partitioning
 
 
 class GenotypePolyPredicate(PolyPredicate[Categorization], metaclass=abc.ABCMeta):
     """
     `GenotypePolyPredicate` is a base class for all :class:`~gpsea.analysis.predicate.PolyPredicate`
-    that test the genotype axis.
+    that assign an individual into a group based on the genotype.
     """
     pass
 
 
-class VariantPredicate(metaclass=abc.ABCMeta):
+class VariantPredicate(Partitioning, metaclass=abc.ABCMeta):
     """
     `VariantPredicate` tests if a variant meets a certain criterion.
 
@@ -23,12 +25,16 @@ class VariantPredicate(metaclass=abc.ABCMeta):
     We *strongly* recommend implementing ``__str__`` and ``__repr__`` as well.
     """
 
-    @abc.abstractmethod
     def get_question(self) -> str:
         """
         Prepare a `str` with the question the predicate can answer.
         """
-        pass
+        # TODO: remove in `v1.0.0`
+        warnings.warn(
+            "`get_question` will be removed soon. Use `description` property instead",
+            DeprecationWarning, stacklevel=2,
+        )
+        return self.description
 
     @abc.abstractmethod
     def test(self, variant: Variant) -> bool:
@@ -102,13 +108,39 @@ def __init__(
     @property
     def predicates(self) -> typing.Sequence[VariantPredicate]:
         return self._predicates
+    
+    @property
+    def name(self) -> str:
+        sep = f" {self._separator_symbol()} "
+        return "(" + sep.join(p.name for p in self._predicates) + ")"
+
+    @property
+    def description(self) -> str:
+        sep = f" {self._separator_word().upper()} "
+        return "(" + sep.join(p.description for p in self._predicates) + ")"
+
+    @property
+    def variable_name(self) -> str:
+        sep = f" {self._separator_symbol()} "
+        return "(" + sep.join(p.variable_name for p in self._predicates) + ")"
+
+    @abc.abstractmethod
+    def _separator_symbol(self) -> str:
+        pass
+
+    @abc.abstractmethod
+    def _separator_word(self) -> str:
+        pass
 
 
 class AnyVariantPredicate(LogicalVariantPredicate):
     # NOT PART OF THE PUBLIC API
 
-    def get_question(self) -> str:
-        return '(' + ' OR '.join(predicate.get_question() for predicate in self._predicates) + ')'
+    def _separator_symbol(self) -> str:
+        return "|"
+    
+    def _separator_word(self) -> str:
+        return "or"
 
     def test(self, variant: Variant) -> bool:
         return any(predicate.test(variant) for predicate in self._predicates)
@@ -131,8 +163,11 @@ def __repr__(self) -> str:
 class AllVariantPredicate(LogicalVariantPredicate):
     # NOT PART OF THE PUBLIC API
 
-    def get_question(self) -> str:
-        return '(' + ' AND '.join(predicate.get_question() for predicate in self._predicates) + ')'
+    def _separator_symbol(self) -> str:
+        return "&"
+    
+    def _separator_word(self) -> str:
+        return "and"
 
     def test(self, variant: Variant) -> bool:
         return all(predicate.test(variant) for predicate in self._predicates)
@@ -161,8 +196,17 @@ def __init__(
     ):
         self._inner = inner
 
-    def get_question(self) -> str:
-        return 'NOT ' + self._inner.get_question()
+    @property
+    def name(self) -> str:
+        return "NOT " + self._inner.name
+
+    @property
+    def description(self) -> str:
+        return "NOT " + self._inner.description
+
+    @property
+    def variable_name(self) -> str:
+        return "NOT " + self._inner.variable_name
 
     def test(self, variant: Variant) -> bool:
         return not self._inner.test(variant)
diff --git a/src/gpsea/analysis/predicate/genotype/_gt_predicates.py b/src/gpsea/analysis/predicate/genotype/_gt_predicates.py
index 61323b27..1bc6ff7c 100644
--- a/src/gpsea/analysis/predicate/genotype/_gt_predicates.py
+++ b/src/gpsea/analysis/predicate/genotype/_gt_predicates.py
@@ -1,11 +1,10 @@
-import dataclasses
 import typing
 
-from collections import defaultdict, Counter
+from collections import Counter
 
 import hpotk
 
-from gpsea.model import Patient, Sex
+from gpsea.model import Patient
 
 from .._api import Categorization, PatientCategory
 from ._api import GenotypePolyPredicate
@@ -14,109 +13,7 @@
 from ._variant import VariantPredicates
 
 
-class AlleleCountingGroupsPredicate(GenotypePolyPredicate):
-    # NOT PART OF THE PUBLIC API
-
-    def __init__(
-        self,
-        counters: typing.Iterable[AlleleCounter],
-        categorizations: typing.Iterable[Categorization],
-    ):
-        self._counters = tuple(counters)
-        self._categorizations = tuple(categorizations)
-        self._question = "Genotype group"
-
-    def get_categorizations(self) -> typing.Sequence[Categorization]:
-        return self._categorizations
-
-    def get_question_base(self) -> str:
-        return self._question
-
-    def test(self, patient: Patient) -> typing.Optional[Categorization]:
-        self._check_patient(patient)
-
-        nonzero_counter_idxs = []
-        for i, counter in enumerate(self._counters):
-            allele_count = counter.count(patient)
-            if allele_count > 0:
-                nonzero_counter_idxs.append(i)
-
-        if len(nonzero_counter_idxs) == 1:
-            return self._categorizations[nonzero_counter_idxs[0]]
-        else:
-            # Patient can be assigned either into no group or into multiple groups.
-            return None
-
-    def __eq__(self, value: object) -> bool:
-        return (
-            isinstance(value, AlleleCountingGroupsPredicate)
-            and self._counters == value._counters
-            and self._categorizations == value._categorizations
-        )
-
-    def __hash__(self) -> int:
-        return hash(
-            (
-                self._counters,
-                self._categorizations,
-            )
-        )
-
-    def __str__(self) -> str:
-        return self.get_question_base()
-
-    def __repr__(self) -> str:
-        return (
-            "AlleleCountingGroupsPredicate("
-            + "counters={self._counters}, "
-            + "categorizations={self._categorizations})"
-        )
-
-
-def groups_predicate(
-    predicates: typing.Iterable[VariantPredicate],
-    group_names: typing.Iterable[str],
-) -> GenotypePolyPredicate:
-    """
-    Create a genotype predicate that bins the patient into one of *n* groups.
-
-    The genotype groups *should* not overlap.
-    In case of an overlap, the patient will be assigned into no group (`None`).
-
-    See the :ref:`groups-predicate` section for an example.
-
-    :param predicates: an iterable with at least 2 variant predicates to determine a genotype group.
-    :param group_names: an iterable with group names. The number of group names must match the number of predicates.
-    """
-    # First, collect the iterables and check sanity.
-    predicates = tuple(predicates)
-    group_names = tuple(group_names)
-
-    assert len(predicates) >= 2, f"We need at least 2 predicates: {len(predicates)}"
-    assert len(predicates) == len(
-        group_names
-    ), f"The number of group names must match the number of predicates: {len(group_names)}!={len(predicates)}"
-
-    # Then, prepare the counters and categorizations.
-    counters = [AlleleCounter(predicate=predicate) for predicate in predicates]
-
-    categorizations = []
-    for i, (predicate, name) in enumerate(zip(predicates, group_names)):
-        categorization = Categorization.from_raw_parts(
-            cat_id=i,
-            name=name,
-            description=predicate.get_question(),
-        )
-        categorizations.append(categorization)
-
-    # Last, put the predicate together.
-    return AlleleCountingGroupsPredicate(
-        counters=counters,
-        categorizations=categorizations,
-    )
-
-
-def fixate_partitions(
+def _fixate_partitions(
     partitions: typing.Collection[typing.Collection[int]],
 ) -> typing.Collection[typing.Sequence[int]]:
     fixed = []
@@ -125,7 +22,7 @@ def fixate_partitions(
     return fixed
 
 
-def qc_partitions(
+def _qc_partitions(
     partitions: typing.Collection[typing.Collection[int]],
 ):
     # NOT PART OF THE PUBLIC API
@@ -164,7 +61,7 @@ def qc_partitions(
         raise ValueError(", ".join(errors))
 
 
-def build_count_to_cat(
+def _build_count_to_cat(
     a_label: str,
     b_label: str,
     partitions: typing.Iterable[typing.Iterable[int]],
@@ -196,7 +93,7 @@ def build_count_to_cat(
     return ac2cat
 
 
-def deduplicate_categorizations(
+def _deduplicate_categorizations(
     cats: typing.Iterable[Categorization],
 ) -> typing.Sequence[Categorization]:
     return sorted(
@@ -205,6 +102,22 @@ def deduplicate_categorizations(
     )
 
 
+def _compute_hash(
+    count2cat: typing.Mapping[typing.Any, typing.Any],
+    counters: typing.Iterable[typing.Hashable],
+) -> int:
+    hash_value = 17
+
+    for key, val in count2cat.items():
+        hash_value += 13 * hash(key)
+        hash_value += 13 * hash(val)
+
+    for counter in counters:
+        hash_value += 23 * hash(counter)
+
+    return hash_value
+
+
 class PolyCountingGenotypePredicate(GenotypePolyPredicate):
     # NOT PART OF THE PUBLIC API
 
@@ -229,6 +142,7 @@ def monoallelic(
         }
 
         return PolyCountingGenotypePredicate.for_predicates_and_categories(
+            total_count=1,
             count2cat=count2cat,
             a_predicate=a_predicate,
             b_predicate=b_predicate,
@@ -242,13 +156,14 @@ def biallelic(
         b_label: str,
         partitions: typing.Iterable[typing.Iterable[int]],
     ) -> "PolyCountingGenotypePredicate":
-        count2cat = build_count_to_cat(
+        count2cat = _build_count_to_cat(
             a_label=a_label,
             b_label=b_label,
             partitions=partitions,
         )
 
         return PolyCountingGenotypePredicate.for_predicates_and_categories(
+            total_count=2,
             count2cat=count2cat,
             a_predicate=a_predicate,
             b_predicate=b_predicate,
@@ -256,11 +171,13 @@ def biallelic(
 
     @staticmethod
     def for_predicates_and_categories(
+        total_count: int,
         count2cat: typing.Mapping[typing.Tuple[int, int], Categorization],
         a_predicate: VariantPredicate,
         b_predicate: VariantPredicate,
     ) -> "PolyCountingGenotypePredicate":
         return PolyCountingGenotypePredicate(
+            total_count=total_count,
             a_counter=AlleleCounter(a_predicate),
             b_counter=AlleleCounter(b_predicate),
             count2cat=count2cat,
@@ -268,33 +185,32 @@ def for_predicates_and_categories(
 
     def __init__(
         self,
+        total_count: int,
         count2cat: typing.Mapping[typing.Tuple[int, int], Categorization],
         a_counter: AlleleCounter,
         b_counter: AlleleCounter,
     ):
+        self._total_count = total_count
         self._count2cat = dict(count2cat)
-        self._categorizations = tuple(deduplicate_categorizations(count2cat.values()))
+        self._categorizations = tuple(_deduplicate_categorizations(count2cat.values()))
         self._a_counter = a_counter
         self._b_counter = b_counter
-        self._hash = self._compute_hash()
-
-    def _compute_hash(self) -> int:
-        hash_value = 17
-
-        self._groups = defaultdict(list)
-        for count, cat in self._count2cat.items():
-            hash_value += 13 * hash(count)
-            hash_value += 13 * hash(cat)
-
-        hash_value += 23 * hash(self._a_counter)
-        hash_value += 23 * hash(self._b_counter)
-
-        return hash_value
+        self._hash = _compute_hash(self._count2cat, (self._a_counter, self._b_counter))
 
     def get_categorizations(self) -> typing.Sequence[Categorization]:
         return self._categorizations
 
-    def get_question_base(self) -> str:
+    @property
+    def name(self) -> str:
+        return "Allele Group Predicate"
+
+    @property
+    def description(self) -> str:
+        allele = "allele" if self._total_count == 1 else "alleles"
+        return f"Partition by allele group ({self._total_count} {allele} per group)"
+
+    @property
+    def variable_name(self) -> str:
         return "Allele group"
 
     def test(self, patient: Patient) -> typing.Optional[Categorization]:
@@ -320,13 +236,13 @@ def __hash__(self) -> int:
 
 def monoallelic_predicate(
     a_predicate: VariantPredicate,
-    b_predicate: VariantPredicate,
+    b_predicate: typing.Optional[VariantPredicate] = None,
     a_label: str = "A",
     b_label: str = "B",
 ) -> GenotypePolyPredicate:
     """
     The predicate bins patient into one of two groups, `A` and `B`,
-    based on presence of *exactly* one allele of a variant
+    based on presence of *exactly one* allele of a variant
     that meets the predicate criteria.
 
     See :ref:`monoallelic-predicate` for more information and an example usage.
@@ -334,13 +250,17 @@ def monoallelic_predicate(
     :param a_predicate: predicate to test if the variants
         meet the criteria of the first group (named `A` by default).
     :param b_predicate: predicate to test if the variants
-        meet the criteria of the second group (named `B` by default).
+        meet the criteria of the second group or `None`
+        if the inverse of `a_predicate` should be used (named `B` by default).
     :param a_label: display name of the `a_predicate` (default ``"A"``).
     :param b_label: display name of the `b_predicate` (default ``"B"``).
     """
     assert isinstance(a_label, str)
     assert isinstance(b_label, str)
 
+    if b_predicate is None:
+        b_predicate = ~a_predicate
+
     return PolyCountingGenotypePredicate.monoallelic(
         a_predicate=a_predicate,
         b_predicate=b_predicate,
@@ -351,7 +271,7 @@ def monoallelic_predicate(
 
 def biallelic_predicate(
     a_predicate: VariantPredicate,
-    b_predicate: VariantPredicate,
+    b_predicate: typing.Optional[VariantPredicate] = None,
     a_label: str = "A",
     b_label: str = "B",
     partitions: typing.Collection[typing.Collection[int]] = ((0,), (1,), (2,)),
@@ -364,8 +284,11 @@ def biallelic_predicate(
 
     See :ref:`biallelic-predicate` for more information and an example usage.
 
-    :param a_predicate: predicate to test if the variants meet the criteria of the first group (named `A` by default).
-    :param b_predicate: predicate to test if the variants meet the criteria of the second group (named `B` by default).
+    :param a_predicate: predicate to test if the variants meet
+        the criteria of the first group (named `A` by default).
+    :param b_predicate: predicate to test if the variants meet
+        the criteria of the second group or `None` if an inverse
+        of `a_predicate` should be used (named `B` by default).
     :param a_label: display name of the `a_predicate` (default ``"A"``).
     :param b_label: display name of the `b_predicate` (default ``"B"``).
     :param partitions: a sequence with partition identifiers (default ``((0,), (1,), (2,))``).
@@ -374,8 +297,11 @@ def biallelic_predicate(
     assert isinstance(a_label, str)
     assert isinstance(b_label, str)
 
-    partitions = fixate_partitions(partitions)
-    qc_partitions(partitions)
+    partitions = _fixate_partitions(partitions)
+    _qc_partitions(partitions)
+
+    if b_predicate is None:
+        b_predicate = ~a_predicate
 
     return PolyCountingGenotypePredicate.biallelic(
         a_predicate=a_predicate,
@@ -386,296 +312,125 @@ def biallelic_predicate(
     )
 
 
-def autosomal_dominant(
-    variant_predicate: typing.Optional[VariantPredicate] = None,
-) -> GenotypePolyPredicate:
-    """
-    Create a predicate that assigns the patient either
-    into homozygous reference or heterozygous
-    group in line with the autosomal dominant mode of inheritance.
-
-    See :ref:`mode-of-inheritance-predicate` for more info and an example usage.
-
-    :param variant_predicate: a predicate for choosing the variants for testing
-        or `None` if all variants should be used.
-    """
-    if variant_predicate is None:
-        variant_predicate = VariantPredicates.true()
-
-    return ModeOfInheritancePredicate.from_moi_info(
-        variant_predicate=variant_predicate,
-        mode_of_inheritance_data=ModeOfInheritanceInfo.autosomal_dominant(),
-        partitions=((0,), (1,)),
-    )
-
-
-def autosomal_recessive(
-    variant_predicate: typing.Optional[VariantPredicate] = None,
-    partitions: typing.Collection[typing.Collection[int]] = ((0,), (1,), (2,)),
-) -> GenotypePolyPredicate:
-    """
-    Create a predicate that assigns the patient either into
-    homozygous reference, heterozygous, or biallelic alternative allele
-    (homozygous alternative or compound heterozygous)
-    group in line with the autosomal recessive mode of inheritance.
-
-    See :ref:`mode-of-inheritance-predicate` for more info and an example usage.
-
-    :param variant_predicate: a predicate for choosing the variants for testing
-        or `None` if all variants should be used
-    :param partitions: a sequence with partition identifiers (default ``((0,), (1,), (2,))``).
-    """
-    if variant_predicate is None:
-        variant_predicate = VariantPredicates.true()
-
-    partitions = fixate_partitions(partitions)
-
-    return ModeOfInheritancePredicate.from_moi_info(
-        variant_predicate=variant_predicate,
-        mode_of_inheritance_data=ModeOfInheritanceInfo.autosomal_recessive(),
-        partitions=partitions,
-    )
-
-
-@dataclasses.dataclass(frozen=True)
-class GenotypeGroup:
-    allele_count: int
-    sex: typing.Optional[Sex]
-    categorization: Categorization
-
-
-class ModeOfInheritanceInfo:
-
-    # NOT PART OF THE PUBLIC API!!!
-
-    HOM_REF = Categorization(
-        PatientCategory(
-            cat_id=0,
-            name="No allele",
-            description="No allele",
-        ),
-    )
-    HET = Categorization(
-        PatientCategory(
-            cat_id=1,
-            name="Monoallelic",
-            description="Monoallelic",
-        ),
-    )
-    BIALLELIC_ALT = Categorization(
-        PatientCategory(
-            cat_id=2,
-            name="Biallelic",
-            description="Homozygous alternate or compound heterozygous",
-        ),
+def _build_ac_to_cat(
+    partitions: typing.Collection[typing.Collection[int]],
+) -> typing.Mapping[int, Categorization]:
+    labels = (
+        "zero",
+        "one",
+        "two",
     )
 
-    @staticmethod
-    def autosomal_dominant() -> "ModeOfInheritanceInfo":
-        groups = (
-            GenotypeGroup(
-                allele_count=0,
-                sex=None,
-                categorization=ModeOfInheritanceInfo.HOM_REF,
-            ),
-            GenotypeGroup(
-                allele_count=1,
-                sex=None,
-                categorization=ModeOfInheritanceInfo.HET,
-            ),
-        )
-        return ModeOfInheritanceInfo(
-            groups=groups,
-        )
-
-    @staticmethod
-    def autosomal_recessive() -> "ModeOfInheritanceInfo":
-        groups = (
-            GenotypeGroup(
-                allele_count=0,
-                sex=None,
-                categorization=ModeOfInheritanceInfo.HOM_REF,
-            ),
-            GenotypeGroup(
-                allele_count=1,
-                sex=None,
-                categorization=ModeOfInheritanceInfo.HET,
-            ),
-            GenotypeGroup(
-                allele_count=2,
-                sex=None,
-                categorization=ModeOfInheritanceInfo.BIALLELIC_ALT,
-            ),
-        )
-        return ModeOfInheritanceInfo(
-            groups=groups,
+    ac2cat = {}
+    for i, partition in enumerate(partitions):
+        name = " OR ".join(str(j) for j in partition)
+        description = " OR ".join(labels[j] for j in partition)
+        cat = Categorization(
+            PatientCategory(cat_id=i, name=name, description=description),
         )
+        for j in partition:
+            ac2cat[j] = cat
 
-    def __init__(
-        self,
-        groups: typing.Iterable[GenotypeGroup],
-    ):
-        # We want this to be hashable but also keep a non-hashable dict
-        # as a field. Therefore, we pre-compute the hash manually.
-        # The correctness depends on two default dicts with same keys and values
-        # comparing equal.
-        hash_value = 17
-
-        self._groups = defaultdict(list)
-        for group in groups:
-            assert isinstance(group, GenotypeGroup)
-            self._groups[group.allele_count].append(group)
-            hash_value += 13 * hash(group)
-
-        self._hash = hash_value
+    return ac2cat
 
-    @property
-    def groups(self) -> typing.Iterator[GenotypeGroup]:
-        # Flatten `values()` which is an iterable of lists.
-        return (group for meta_group in self._groups.values() for group in meta_group)
 
-    def get_groups_for_allele_count(
-        self,
-        allele_count: int,
-    ) -> typing.Sequence[GenotypeGroup]:
-        try:
-            return self._groups[allele_count]
-        except KeyError:
-            # No group for this allele count is OK
-            return ()
+def allele_count(
+    counts: typing.Collection[typing.Collection[int]],
+    target: typing.Optional[VariantPredicate] = None,
+) -> GenotypePolyPredicate:
+    """
+    Create a predicate to assign the patient into a group based on the allele count
+    of the target variants.
 
-    def __eq__(self, value: object) -> bool:
-        return (
-            isinstance(value, ModeOfInheritanceInfo) and self._groups == value._groups
-        )
+    The `counts` option takes a collection of `int` collections.
+    Each inner collection corresponds to an allele count partition
+    and the outer collection includes all partitions.
+    The `int` represents the target allele count.
+    A count can be included only in one partition.
 
-    def __hash__(self) -> int:
-        return self._hash
+    Examples
+    --------
 
-    def __str__(self) -> str:
-        return f"ModeOfInheritanceInfo(groups={self._groups})"
+    The following counts will partition the cohort into individuals
+    with zero allele or one target allele:
 
-    def __repr__(self) -> str:
-        return str(self)
+    >>> from gpsea.analysis.predicate.genotype import allele_count
+    >>> zero_vs_one = allele_count(counts=({0,}, {1,}))
+    >>> zero_vs_one.summarize_groups()
+    'Allele count: 0, 1'
+    
+    These counts will create three groups for individuals with zero, one or two alleles:
 
+    >>> zero_vs_one_vs_two = allele_count(counts=({0,}, {1,}, {2,}))
+    >>> zero_vs_one_vs_two.summarize_groups()
+    'Allele count: 0, 1, 2'
 
-class ModeOfInheritancePredicate(GenotypePolyPredicate):
-    # NOT PART OF THE PUBLIC API!!!
-    """
-    `ModeOfInheritancePredicate` assigns an individual into a group based on compatibility with
-    the selected mode of inheritance.
+    :param counts: a sequence with allele count partitions.
+    :param target: a predicate for choosing the variants for testing
+        or `None` if *all* variants in the individual should be used.
     """
+    if target is None:
+        target = VariantPredicates.true()
+    else:
+        assert isinstance(target, VariantPredicate)
 
-    @staticmethod
-    def from_moi_info(
-        variant_predicate: VariantPredicate,
-        mode_of_inheritance_data: ModeOfInheritanceInfo,
-        partitions: typing.Collection[typing.Collection[int]],
-    ) -> "ModeOfInheritancePredicate":
-        """
-        Create a predicate for specified mode of inheritance data.
-        """
-        qc_partitions(partitions=partitions)
-
-        allele_counter = AlleleCounter(predicate=variant_predicate)
-        count2cat = ModeOfInheritancePredicate.prepare_count2cat(
-            mode_of_inheritance_data=mode_of_inheritance_data,
-            partitions=partitions,
-        )
-        return ModeOfInheritancePredicate(
-            allele_counter=allele_counter,
-            count2cat=count2cat,
-        )
+    counts = _fixate_partitions(counts)
+    _qc_partitions(counts)
 
-    @staticmethod
-    def prepare_count2cat(
-        mode_of_inheritance_data: ModeOfInheritanceInfo,
-        partitions: typing.Collection[typing.Collection[int]],
-    ) -> typing.Mapping[int, Categorization]:
-        groups = tuple(mode_of_inheritance_data.groups)
-        partition_to_allele_count = tuple(range(len(groups)))
-        partition_to_label = tuple(
-            group.categorization.category.name for group in groups
-        )
+    count2cat = _build_ac_to_cat(counts)
 
-        count2cat = {}
-        for i, partition in enumerate(partitions):
-            label = " OR ".join(partition_to_label[j] for j in partition)
+    counter = AlleleCounter(predicate=target)
+    
+    return AlleleCountPredicate(
+        count2cat=count2cat,
+        counter=counter,
+    )
 
-            cat = Categorization(
-                PatientCategory(
-                    cat_id=i,
-                    name=label,
-                    description=label,
-                )
-            )
-            for p in partition:
-                allele_count = partition_to_allele_count[p]
-                count2cat[allele_count] = cat
 
-        return count2cat
+class AlleleCountPredicate(GenotypePolyPredicate):
 
     def __init__(
         self,
-        allele_counter: AlleleCounter,
         count2cat: typing.Mapping[int, Categorization],
+        counter: AlleleCounter,
     ):
-        assert isinstance(allele_counter, AlleleCounter)
-        self._allele_counter = allele_counter
-
         self._count2cat = dict(count2cat)
-        self._categorizations = tuple(deduplicate_categorizations(count2cat.values()))
 
-        self._question = "What is the genotype group"
-        self._hash = self._compute_hash()
+        assert isinstance(counter, AlleleCounter)
+        self._counter = counter
 
-    def _compute_hash(self) -> int:
-        hash_value = 17
-
-        self._groups = defaultdict(list)
-        for count, cat in self._count2cat.items():
-            hash_value += 13 * hash(count)
-            hash_value += 13 * hash(cat)
-
-        hash_value += 23 * hash(self._allele_counter)
-
-        return hash_value
+        self._categorizations = tuple(_deduplicate_categorizations(self._count2cat.values()))
+        self._hash = _compute_hash(self._count2cat, (self._counter,))
 
     def get_categorizations(self) -> typing.Sequence[Categorization]:
         return self._categorizations
 
-    def get_question_base(self) -> str:
-        return self._question
+    @property
+    def name(self) -> str:
+        return "Allele Count Predicate"
 
-    def test(
-        self,
-        patient: Patient,
-    ) -> typing.Optional[Categorization]:
+    @property
+    def description(self) -> str:
+        return "Partition by the allele count"
+
+    @property
+    def variable_name(self) -> str:
+        return "Allele count"
+
+    def test(self, patient: Patient) -> typing.Optional[Categorization]:
         self._check_patient(patient)
 
-        allele_count = self._allele_counter.count(patient)
-        return self._count2cat.get(allele_count, None)
+        count = self._counter.count(patient)
+        return self._count2cat.get(count, None)
 
     def __eq__(self, value: object) -> bool:
-        return (
-            isinstance(value, ModeOfInheritancePredicate)
-            and self._allele_counter == value._allele_counter
-            and self._count2cat == value._count2cat
-        )
+        return isinstance(value, AlleleCountPredicate) \
+            and self._count2cat == value._count2cat \
+            and self._counter == value._counter
 
     def __hash__(self) -> int:
         return self._hash
 
-    def __str__(self) -> str:
-        return (
-            "ModeOfInheritancePredicate("
-            f"allele_counter={self._allele_counter}, "
-            f"count2cat={self._count2cat})"
-        )
-
-    def __repr__(self) -> str:
-        return str(self)
-
 
 class SexGenotypePredicate(GenotypePolyPredicate):
     # NOT PART OF THE PUBLIC API
@@ -701,8 +456,17 @@ def __init__(self):
     def get_categorizations(self) -> typing.Sequence[Categorization]:
         return self._categorizations
 
-    def get_question_base(self) -> str:
-        return "Sex of the individual"
+    @property
+    def name(self) -> str:
+        return "Sex Predicate"
+
+    @property
+    def description(self) -> str:
+        return "Partition by sex"
+
+    @property
+    def variable_name(self) -> str:
+        return "Sex"
 
     def test(self, patient: Patient) -> typing.Optional[Categorization]:
         if patient.sex.is_provided():
@@ -729,7 +493,7 @@ def sex_predicate() -> GenotypePolyPredicate:
     """
     Get a genotype predicate for categorizing patients by their :class:`~gpsea.model.Sex`.
 
-    See the :ref:`male-female-predicate` section for an example.
+    See the :ref:`group-by-sex` section for an example.
     """
     return INSTANCE
 
@@ -786,12 +550,22 @@ def __init__(
         )
         self._hash = hash(tuple(categorizations.items()))
 
+    @property
+    def name(self) -> str:
+        return "Diagnosis Predicate"
+
+    @property
+    def description(self) -> str:
+        diagnoses = ", ".join(cat.category.name for cat in self._categorizations)
+        return f"Partition the individual by presence of {diagnoses}"
+
+    @property
+    def variable_name(self) -> str:
+        return "Diagnosis"
+
     def get_categorizations(self) -> typing.Sequence[Categorization]:
         return self._categorizations
 
-    def get_question_base(self) -> str:
-        return "What disease was diagnosed"
-
     def test(self, patient: Patient) -> typing.Optional[Categorization]:
         categorization = None
         for disease in patient.diseases:
@@ -829,11 +603,11 @@ def diagnosis_predicate(
     If an individual is diagnosed with more than one disease from the provided `diagnoses`,
     the individual will be assigned into no group (`None`).
 
-    See the :ref:`diagnosis-predicate` section for an example.
+    See the :ref:`group-by-diagnosis` section for an example.
 
-    :param diagnoses: an iterable with at least 2 diagnose IDs, either as a `str` or a :class:`~hpotk.TermId`
+    :param diagnoses: an iterable with at least 2 disease IDs, either as a `str` or a :class:`~hpotk.TermId`
       to determine the genotype group.
-    :param labels: an iterable with diagnose names or `None` if CURIEs should be used instead.
+    :param labels: an iterable with diagnose names or `None` if disease IDs should be used instead.
       The number of labels must match the number of predicates.
     """
     return DiagnosisPredicate.create(
diff --git a/src/gpsea/analysis/predicate/genotype/_predicates.py b/src/gpsea/analysis/predicate/genotype/_predicates.py
index b1ac93e6..dc988260 100644
--- a/src/gpsea/analysis/predicate/genotype/_predicates.py
+++ b/src/gpsea/analysis/predicate/genotype/_predicates.py
@@ -3,9 +3,8 @@
 
 import operator
 
-from gpsea.model import Variant, VariantEffect, VariantClass, FeatureType
+from gpsea.model import Variant, VariantEffect, VariantClass, FeatureType, ProteinMetadata
 from gpsea.model.genome import Region
-from gpsea.preprocessing import ProteinMetadataService
 
 from ._api import VariantPredicate
 
@@ -19,8 +18,17 @@ class AlwaysTrueVariantPredicate(VariantPredicate):
     def get_instance() -> "AlwaysTrueVariantPredicate":
         return ALWAYS_TRUE
 
-    def get_question(self) -> str:
-        return ""
+    @property
+    def name(self) -> str:
+        return "Always True"
+
+    @property
+    def description(self) -> str:
+        return "true"
+
+    @property
+    def variable_name(self) -> str:
+        return "N/A"
 
     def test(self, variant: Variant) -> bool:
         return True
@@ -54,9 +62,18 @@ class VariantEffectPredicate(VariantPredicate):
     def __init__(self, effect: VariantEffect, tx_id: str) -> None:
         self._effect = effect
         self._tx_id = tx_id
-        
-    def get_question(self) -> str:
-        return f'{self._effect.name} on {self._tx_id}'
+    
+    @property
+    def name(self) -> str:
+        return "Variant Effect"
+
+    @property
+    def description(self) -> str:
+        return f"{self._effect.name} on {self._tx_id}"
+
+    @property
+    def variable_name(self) -> str:
+        return "variant effect"
 
     def test(self, variant: Variant) -> bool:
         tx_anno = variant.get_tx_anno_by_tx_id(self._tx_id)
@@ -95,9 +112,18 @@ class VariantKeyPredicate(VariantPredicate):
     
     def __init__(self, key: str) -> None:
         self._key = key
-        
-    def get_question(self) -> str:
-        return f'variant has ID of {self._key}'
+
+    @property
+    def name(self) -> str:
+        return "Variant Key Predicate"
+
+    @property
+    def description(self) -> str:
+        return f"variant key is {self._key}"
+
+    @property
+    def variable_name(self) -> str:
+        return "variant key"
 
     def test(self, variant: Variant) -> bool:
         return self._key == variant.variant_info.variant_key
@@ -129,8 +155,17 @@ class VariantGenePredicate(VariantPredicate):
     def __init__(self, symbol: str) -> None:
         self._symbol = symbol
 
-    def get_question(self) -> str:
-        return f'impacts {self._symbol}'
+    @property
+    def name(self) -> str:
+        return "Gene Predicate"
+
+    @property
+    def description(self) -> str:
+        return f"affects {self._symbol}"
+
+    @property
+    def variable_name(self) -> str:
+        return "gene"
 
     def test(self, variant: Variant) -> bool:
         for tx in variant.tx_annotations:
@@ -165,8 +200,17 @@ class VariantTranscriptPredicate(VariantPredicate):
     def __init__(self, tx_id: str) -> None:
         self._tx_id = tx_id
 
-    def get_question(self) -> str:
-        return f'variant affects transcript {self._tx_id}'
+    @property
+    def name(self) -> str:
+        return "Transcript Predicate"
+
+    @property
+    def description(self) -> str:
+        return f"affects {self._tx_id}"
+
+    @property
+    def variable_name(self) -> str:
+        return "transcript"
         
     def test(self, variant: Variant) -> bool:
         for tx in variant.tx_annotations:
@@ -222,9 +266,18 @@ def __init__(
         self._exon = exon
         assert isinstance(tx_id, str)
         self._tx_id = tx_id
-        
-    def get_question(self) -> str:
-        return f'variant affects exon {self._exon} on {self._tx_id}'
+    
+    @property
+    def name(self) -> str:
+        return "Exon Predicate"
+
+    @property
+    def description(self) -> str:
+        return f"overlaps with exon {self._exon} of {self._tx_id}"
+
+    @property
+    def variable_name(self) -> str:
+        return "exon"
 
     def test(self, variant: Variant) -> bool:
         tx_anno = variant.get_tx_anno_by_tx_id(self._tx_id)
@@ -253,12 +306,21 @@ def __repr__(self):
 
 class IsLargeImpreciseStructuralVariantPredicate(VariantPredicate):
     """
-    `IsLargeImpreciseStructuralVariantPredicate` tests if the variant is a large imprecise SV 
+    `IsLargeImpreciseStructuralVariantPredicate` tests if the variant is a large imprecise SV
     where the exact breakpoint coordinates are not available.
     """
 
-    def get_question(self) -> str:
-        return 'is large imprecise structural variant'
+    @property
+    def name(self) -> str:
+        return "Large Imprecise SV"
+
+    @property
+    def description(self) -> str:
+        return "is large imprecise structural variant"
+
+    @property
+    def variable_name(self) -> str:
+        return "variant"
 
     def test(self, variant: Variant) -> bool:
         return variant.variant_info.has_sv_info()
@@ -287,9 +349,18 @@ def __init__(
     ):
         assert isinstance(query, VariantClass), 'query must be `VariantClass`'
         self._query = query
+    
+    @property
+    def name(self) -> str:
+        return "Variant Class"
 
-    def get_question(self) -> str:
-        return f'variant class is {self._query.name}'
+    @property
+    def description(self) -> str:
+        return f"variant class is {self._query.name}"
+
+    @property
+    def variable_name(self) -> str:
+        return "variant class"
 
     def test(self, variant: Variant) -> bool:
         """
@@ -315,7 +386,7 @@ def __repr__(self):
 
 class StructuralTypePredicate(VariantPredicate):
     """
-    `StructuralTypePredicate` tests if the variant has a certain structural type, 
+    `StructuralTypePredicate` tests if the variant has a certain structural type,
     e.g. `chromosomal_deletion` (`SO:1000029 <http://purl.obolibrary.org/obo/SO_1000029>`_).
     """
 
@@ -337,12 +408,22 @@ def __init__(
         assert isinstance(query, hpotk.TermId), 'query must be a `TermId`'
         self._query = query
 
-    def get_question(self) -> str:
-        return f'structural type is {self._query}'
+    @property
+    def name(self) -> str:
+        return "Structural Type"
+
+    @property
+    def description(self) -> str:
+        return f"structural type is {self._query.value}"
+
+    @property
+    def variable_name(self) -> str:
+        return "structural type"
 
     def test(self, variant: Variant) -> bool:
-        if variant.variant_info.has_sv_info():
-            return variant.variant_info.sv_info.structural_type == self._query
+        sv_info = variant.variant_info.sv_info
+        if sv_info is not None:
+            return sv_info.structural_type == self._query
         return False
 
     def __eq__(self, value: object) -> bool:
@@ -391,12 +472,22 @@ def __init__(
         assert isinstance(threshold, int), 'threshold must be an `int`'
         self._threshold = threshold
 
-    def get_question(self) -> str:
-        return f'change length {self._operator_str} {self._threshold}'
+    @property
+    def name(self) -> str:
+        return "Change Length"
+
+    @property
+    def description(self) -> str:
+        return f"change length {self._operator_str} {self._threshold}"
+
+    @property
+    def variable_name(self) -> str:
+        return "change length"
 
     def test(self, variant: Variant) -> bool:
-        if variant.variant_info.has_variant_coordinates():
-            return self._operator(variant.variant_info.variant_coordinates.change_length, self._threshold)
+        vc = variant.variant_info.variant_coordinates
+        if vc is not None:
+            return self._operator(vc.change_length, self._threshold)
         return False
 
     def __eq__(self, value: object) -> bool:
@@ -432,12 +523,22 @@ def __init__(
         assert length >= 0, 'length must be non-negative'
         self._length = length
 
-    def get_question(self) -> str:
-        return f'ref allele length {self._operator_str} {self._length}'
+    @property
+    def name(self) -> str:
+        return "Reference Allele Length"
+
+    @property
+    def description(self) -> str:
+        return f"reference allele length {self._operator_str} {self._length}"
+
+    @property
+    def variable_name(self) -> str:
+        return "length of the reference allele"
 
     def test(self, variant: Variant) -> bool:
-        if variant.variant_info.has_variant_coordinates():
-            return self._operator(len(variant.variant_info.variant_coordinates), self._length)
+        vc = variant.variant_info.variant_coordinates
+        if vc is not None:
+            return self._operator(len(vc), self._length)
         return False
 
     def __eq__(self, value: object) -> bool:
@@ -467,7 +568,7 @@ class ProteinRegionPredicate(VariantPredicate):
     :param region: a `Region` with the start and end coordinates
     :param tx_id: the accession of the transcript of interest
     """
-    
+
     def __init__(
         self,
         region: Region,
@@ -476,9 +577,21 @@ def __init__(
         self._region = region
         self._tx_id = tx_id
         
-    def get_question(self) -> str:
-        return f'variant affects aminoacid(s) between {self._region.start} and {self._region.end} ' \
-            f'on protein encoded by transcript {self._tx_id}'
+    @property
+    def name(self) -> str:
+        return "Protein Region"
+
+    @property
+    def description(self) -> str:
+        return (
+            "overlaps with "
+            f"[{self._region.start + 1},{self._region.end}] region "  # Reporting 1-based coordinates
+            f"of the protein encoded by {self._tx_id}"
+        )
+
+    @property
+    def variable_name(self) -> str:
+        return "overlap with aminoacid region"
 
     def test(self, variant: Variant) -> bool:
         tx_anno = variant.get_tx_anno_by_tx_id(self._tx_id)
@@ -519,44 +632,45 @@ class ProteinFeatureTypePredicate(VariantPredicate):
     def __init__(
         self,
         feature_type: FeatureType,
-        tx_id: str,
-        protein_metadata_service: ProteinMetadataService,
+        protein_metadata: ProteinMetadata,
     ):
+        assert isinstance(feature_type, FeatureType)
         self._feature_type = feature_type
-        self._tx_id = tx_id
-        self._prot_service = protein_metadata_service
         
-    def get_question(self) -> str:
-        return f'variant affects {self._feature_type.name} feature type on the protein ' \
-            f'encoded by transcript {self._tx_id}'
+        assert isinstance(protein_metadata, ProteinMetadata)
+        self._protein_metadata = protein_metadata
+        
+    @property
+    def name(self) -> str:
+        return "Protein Feature Type"
+
+    @property
+    def description(self) -> str:
+        return f"overlaps with a protein feature {self._feature_type.name}"
+
+    @property
+    def variable_name(self) -> str:
+        return f"overlap with {self._feature_type.name} feature"
 
     def test(self, variant: Variant) -> bool:
-        tx_anno = variant.get_tx_anno_by_tx_id(self._tx_id)
-        if tx_anno is None:
-            return False
-        location = tx_anno.protein_effect_location
-        if location is None:
-            return False
-        
-        protein_id = tx_anno.protein_id
-        if protein_id is None:
-            return False
-        
-        protein_meta = self._prot_service.annotate(protein_id)
-        for feat in protein_meta.protein_features:
-            if feat.feature_type == self._feature_type and location.overlaps_with(feat.info.region):
-                return True
+        for tx_ann in variant.tx_annotations:
+            if tx_ann.protein_id is not None and tx_ann.protein_id == self._protein_metadata.protein_id:
+                location = tx_ann.protein_effect_location
+                if location is not None:
+                    return any(
+                        self._feature_type == feature.feature_type and location.overlaps_with(feature.info.region)
+                        for feature in self._protein_metadata.protein_features
+                    )
+                
         return False
 
     def __eq__(self, value: object) -> bool:
-        if isinstance(value, ProteinFeatureTypePredicate):
-            # We do not care about `self._prot_service`
-            return self._feature_type == value._feature_type and self._tx_id == value._tx_id
-        return False
+        return isinstance(value, ProteinFeatureTypePredicate) \
+            and self._feature_type == value._feature_type \
+            and self._protein_metadata == value._protein_metadata
     
     def __hash__(self) -> int:
-        # We do not care about `self._prot_service`
-        return hash((self._feature_type, self._tx_id,))
+        return hash((self._feature_type, self._protein_metadata,))
 
     def __str__(self):
         return repr(self)
@@ -564,15 +678,13 @@ def __str__(self):
     def __repr__(self):
         return 'ProteinFeatureTypePredicate(' \
             f'feature_type={self._feature_type}, ' \
-            f'tx_id={self._tx_id}, ' \
-            f'protein_metadata_service={self._prot_service}' \
-            ')'
+            f'protein_metadata={self._protein_metadata})'
 
 
 class ProteinFeaturePredicate(VariantPredicate):
     """
     `ProteinFeaturePredicate` checks if the variant overlaps with
-    a feature type in the protein encoded by a transcript of interest.
+    a feature with a specific id.
     
     Args:
         feature_type (FeatureType): a member of the `FeatureType` enum
@@ -580,48 +692,54 @@ class ProteinFeaturePredicate(VariantPredicate):
         protein_metadata_service (ProteinMetadataService): a service for fetching protein information
     """
     
-    def __init__(self, feature_name: str, tx_id: str, protein_metadata_service: ProteinMetadataService) -> None:
-        self._feature_name = feature_name
-        self._tx_id = tx_id
-        self._prot_service = protein_metadata_service
+    def __init__(
+        self,
+        feature_id: str,
+        protein_metadata: ProteinMetadata,
+    ):
+        assert isinstance(feature_id, str)
+        self._feature_id = feature_id
+        
+        assert isinstance(protein_metadata, ProteinMetadata)
+        self._protein_metadata = protein_metadata
+
+    @property
+    def name(self) -> str:
+        return "Protein Feature"
 
-    def get_question(self) -> str:
-        return f'Variant that affects {self._feature_name} feature on the protein encoded by transcript {self._tx_id}'
+    @property
+    def description(self) -> str:
+        return f"overlaps with {self._feature_id}"
+
+    @property
+    def variable_name(self) -> str:
+        return "overlap with a protein feature"
         
     def test(self, variant: Variant) -> bool:
-        tx_anno = variant.get_tx_anno_by_tx_id(self._tx_id)
-        if tx_anno is None:
-            return False
+        for tx_ann in variant.tx_annotations:
+            if tx_ann.protein_id is not None and tx_ann.protein_id == self._protein_metadata.protein_id:
+                location = tx_ann.protein_effect_location
+                if location is not None:
+                    return any(
+                        self._feature_id == feature.info.name and location.overlaps_with(feature.info.region)
+                        for feature in self._protein_metadata.protein_features
+                    )
         
-        location = tx_anno.protein_effect_location
-        if location is None:
-            return False
-        
-        protein_id = tx_anno.protein_id
-        if protein_id is None:
-            return False
-
-        protein = self._prot_service.annotate(protein_id)
-        for feat in protein.protein_features:
-            if feat.info.name == self._feature_name and location.overlaps_with(feat.info.region):
-                return True
         return False
     
     def __eq__(self, value: object) -> bool:
-        if isinstance(value, ProteinFeaturePredicate):
-            # We do not care about `self._prot_service`
-            return self._feature_name == value._feature_name and self._tx_id == value._tx_id
-        return False
+        return isinstance(value, ProteinFeaturePredicate) \
+            and self._feature_id == value._feature_id \
+            and self._protein_metadata == value._protein_metadata
     
     def __hash__(self) -> int:
         # We do not care about `self._prot_service`
-        return hash((self._feature_name, self._tx_id,))
+        return hash((self._feature_id, self._protein_metadata,))
 
     def __str__(self):
         return repr(self)
 
     def __repr__(self):
-        return f'ProteinFeaturePredicate(' \
-            f'feature_name={self._feature_name}, ' \
-            f'tx_id={self._tx_id}, ' \
-            f'protein_metadata_service={self._prot_service})'
+        return 'ProteinFeaturePredicate(' \
+            f'feature_id={self._feature_id}, ' \
+            f'protein_metadata={self._protein_metadata})'
diff --git a/src/gpsea/analysis/predicate/genotype/_variant.py b/src/gpsea/analysis/predicate/genotype/_variant.py
index a35b7e08..c725f24f 100644
--- a/src/gpsea/analysis/predicate/genotype/_variant.py
+++ b/src/gpsea/analysis/predicate/genotype/_variant.py
@@ -1,10 +1,26 @@
 import typing
 
-from gpsea.model import VariantEffect, FeatureType
+import hpotk
+
+from gpsea.model import FeatureType, VariantClass, VariantEffect, ProteinMetadata
 from gpsea.model.genome import Region
-from gpsea.preprocessing import ProteinMetadataService
 from ._api import VariantPredicate, AllVariantPredicate, AnyVariantPredicate
-from ._predicates import *
+from ._predicates import (
+    AlwaysTrueVariantPredicate,
+    ChangeLengthPredicate,
+    IsLargeImpreciseStructuralVariantPredicate,
+    ProteinFeaturePredicate,
+    ProteinFeatureTypePredicate,
+    ProteinRegionPredicate,
+    RefAlleleLengthPredicate,
+    StructuralTypePredicate,
+    VariantClassPredicate,
+    VariantEffectPredicate,
+    VariantExonPredicate,
+    VariantGenePredicate,
+    VariantKeyPredicate,
+    VariantTranscriptPredicate,
+)
 
 
 # We do not need more than just one instance of these predicates.
@@ -42,7 +58,7 @@ def all(predicates: typing.Iterable[VariantPredicate]) -> VariantPredicate:
         >>> genes = ('SURF1', 'SURF2',)
         >>> predicate = VariantPredicates.all(VariantPredicates.gene(g) for g in genes)
         >>> predicate.get_question()
-        '(impacts SURF1 AND impacts SURF2)'
+        '(affects SURF1 AND affects SURF2)'
 
         Args:
             predicates: an iterable of predicates to test
@@ -171,15 +187,45 @@ def exon(
         return VariantExonPredicate(exon, tx_id)
 
     @staticmethod
-    def region(region: Region, tx_id: str) -> VariantPredicate:
+    def region(
+        region: typing.Union[typing.Tuple[int, int], Region],
+        tx_id: str,
+    ) -> VariantPredicate:
         """
         Prepare a :class:`VariantPredicate` that tests if the variant
         overlaps with a region on a protein of a specific transcript.
 
+        
+        Example
+        -------
+
+        Create a predicate to test if the variant overlaps with the 5th aminoacid
+        of the protein encoded by a fictional transcript `NM_1234.5`:
+
+        >>> from gpsea.analysis.predicate.genotype import VariantPredicates
+        >>> overlaps_with_fifth_aa = VariantPredicates.region(region=(5, 5), tx_id="NM_1234.5")
+        >>> overlaps_with_fifth_aa.description
+        'overlaps with [5,5] region of the protein encoded by NM_1234.5'
+
+        Create a predicate to test if the variant Overlaps with the first 20 aminoacid residues of the same transcript:
+
+        >>> overlaps_with_first_20 = VariantPredicates.region(region=(1, 20), tx_id="NM_1234.5")
+        >>> overlaps_with_first_20.description
+        'overlaps with [1,20] region of the protein encoded by NM_1234.5'
+
         Args:
             region: a :class:`~gpsea.model.genome.Region` that gives the start and end coordinate
-                of the region of interest on a protein strand.
-        """
+                of the region of interest on a protein strand or a tuple with 1-based coordinates.
+        """
+        if isinstance(region, Region):
+            pass
+        elif isinstance(region, tuple) and len(region) == 2 and all(isinstance(c, int) and c > 0 for c in region):
+            start = region[0] - 1  # Convert to 0-based
+            end = region[1]
+            region = Region(start=start, end=end)
+        else:
+            raise ValueError(f'region must be a `Region` or a tuple with two positive `int`s, but got {region}')
+
         return ProteinRegionPredicate(region, tx_id)
 
     @staticmethod
@@ -245,7 +291,8 @@ def variant_class(
         variant_class: VariantClass,
     ) -> VariantPredicate:
         """
-        Prepare a :class:`VariantPredicate` for testing if the variant is of a certain :class:`~gpsea.model.VariantClass`.
+        Prepare a :class:`VariantPredicate` for testing if the variant
+        is of a certain :class:`~gpsea.model.VariantClass`.
 
         **Example**
 
@@ -283,8 +330,8 @@ def ref_length(
 
         >>> from gpsea.analysis.predicate.genotype import VariantPredicates
         >>> predicate = VariantPredicates.ref_length('>', 5)
-        >>> predicate.get_question()
-        'ref allele length > 5'
+        >>> predicate.description
+        'reference allele length > 5'
 
         Args:
             operator: a `str` with the desired test. Must be one of ``{ '<', '<=', '==', '!=', '>=', '>' }``.
@@ -357,42 +404,39 @@ def is_structural_deletion(
             & VariantPredicates.change_length("<=", threshold)
         )
 
-
-class ProteinPredicates:
-    """
-    `ProteinPredicates` prepares variant predicates that need to consult 
-    :class:`~gpsea.preprocessing.ProteinMetadataService`
-    to categorize a :class:`~gpsea.model.Variant`.
-    """
-
-    def __init__(
-        self,
-        protein_metadata_service: ProteinMetadataService,
-    ):
-        self._protein_metadata_service = protein_metadata_service
-
+    @staticmethod
     def protein_feature_type(
-        self, feature_type: FeatureType, tx_id: str
+        feature_type: FeatureType,
+        protein_metadata: ProteinMetadata,
     ) -> VariantPredicate:
         """
-        Prepare a :class:`VariantPredicate` that tests if the variant affects a protein feature type.
+        Prepare a :class:`~gpsea.analysis.predicate.genotype.VariantPredicate`
+        to test if the variant affects a `feature_type` of a protein.
 
         Args:
             feature_type: the target protein :class:`~gpsea.model.FeatureType`
-                (e.g. :class:`~gpsea.model.FeatureType.DOMAIN`)
+                (e.g. :class:`~gpsea.model.FeatureType.DOMAIN`).
+            protein_metadata: the information about the protein.
         """
         return ProteinFeatureTypePredicate(
-            feature_type, tx_id, self._protein_metadata_service
+            feature_type=feature_type,
+            protein_metadata=protein_metadata,
         )
 
-    def protein_feature(self, feature_id: str, tx_id: str) -> VariantPredicate:
+    @staticmethod
+    def protein_feature(
+        feature_id: str,
+        protein_metadata: ProteinMetadata,
+    ) -> VariantPredicate:
         """
-        Prepare a :class:`VariantPredicate` that tests if the variant affects a protein feature type.
+        Prepare a :class:`VariantPredicate` to test if the variant affects a protein feature
+        labeled with the provided `feature_id`.
 
         Args:
             feature_id: the id of the target protein feature (e.g. `ANK 1`)
-            tx_id: a `str` with the accession ID of the target transcript (e.g. `NM_123.4`)
+            protein_metadata: the information about the protein.
         """
         return ProteinFeaturePredicate(
-            feature_id, tx_id, self._protein_metadata_service
+            feature_id=feature_id,
+            protein_metadata=protein_metadata,
         )
diff --git a/src/gpsea/analysis/predicate/phenotype/_pheno.py b/src/gpsea/analysis/predicate/phenotype/_pheno.py
index 004ea672..b5503a64 100644
--- a/src/gpsea/analysis/predicate/phenotype/_pheno.py
+++ b/src/gpsea/analysis/predicate/phenotype/_pheno.py
@@ -2,10 +2,11 @@
 import typing
 
 import hpotk
+from hpotk.util import validate_instance
 
 from gpsea.model import Patient
 
-from .._api import PolyPredicate, PatientCategory, PatientCategories, Categorization
+from .._api import PolyPredicate, PatientCategory, Categorization
 
 P = typing.TypeVar("P")
 """
@@ -15,6 +16,16 @@
 (have `__eq__` and `__lt__` magic methods).
 """
 
+YES = PatientCategory(1, 'Yes', 'The patient belongs to the group.')
+"""
+Category for a patient who *belongs* to the tested group.
+"""
+
+NO = PatientCategory(0, 'No', 'The patient does not belong to the group.')
+"""
+Category for a patient who does *not* belong to the tested group.
+"""
+
 
 class PhenotypeCategorization(typing.Generic[P], Categorization):
     """
@@ -56,7 +67,9 @@ class PhenotypePolyPredicate(
     typing.Generic[P], PolyPredicate[PhenotypeCategorization[P]], metaclass=abc.ABCMeta
 ):
     """
-    `PhenotypePolyPredicate` investigates a patient in context of one or more phenotype categories `P`.
+    `PhenotypePolyPredicate` is a base class for all :class:`~gpsea.analysis.predicate.PolyPredicate`
+    that assigns an individual into a group based on phenotype.
+    The predicate assigns an individual into one of phenotype categories `P`.
 
     Each phenotype category `P` can be a :class:`~hpotk.model.TermId` representing an HPO term or an OMIM/MONDO term.
 
@@ -111,30 +124,39 @@ def __init__(
         query: hpotk.TermId,
         missing_implies_phenotype_excluded: bool = False,
     ):
-        self._hpo = hpotk.util.validate_instance(hpo, hpotk.MinimalOntology, "hpo")
-        self._query = hpotk.util.validate_instance(
+        self._hpo = validate_instance(hpo, hpotk.MinimalOntology, "hpo")
+        self._query = validate_instance(
             query, hpotk.TermId, "phenotypic_feature"
         )
         self._query_label = self._hpo.get_term_name(query)
         assert self._query_label is not None, f"Query {query} is in HPO"
-        self._missing_implies_phenotype_excluded = hpotk.util.validate_instance(
+        self._missing_implies_phenotype_excluded = validate_instance(
             missing_implies_phenotype_excluded,
             bool,
             "missing_implies_phenotype_excluded",
         )
         self._phenotype_observed = PhenotypeCategorization(
-            category=PatientCategories.YES,
+            category=YES,
             phenotype=self._query,
         )
         self._phenotype_excluded = PhenotypeCategorization(
-            category=PatientCategories.NO,
+            category=NO,
             phenotype=self._query,
         )
         # Some tests depend on the order of `self._categorizations`.
         self._categorizations = (self._phenotype_observed, self._phenotype_excluded)
 
-    def get_question_base(self) -> str:
-        return f"Is {self._query_label} present in the patient"
+    @property
+    def name(self) -> str:
+        return "HPO Predicate"
+
+    @property
+    def description(self) -> str:
+        return f"Test for presence of {self._query_label} [{self._query.value}]"
+
+    @property
+    def variable_name(self) -> str:
+        return self._query.value
 
     @property
     def phenotype(self) -> hpotk.TermId:
@@ -176,22 +198,18 @@ def test(
 
         for phenotype in patient.phenotypes:
             if phenotype.is_present:
-                if any(
+                if self._query == phenotype.identifier or any(
                     self._query == anc
-                    for anc in self._hpo.graph.get_ancestors(
-                        phenotype, include_source=True
-                    )
+                    for anc in self._hpo.graph.get_ancestors(phenotype)
                 ):
                     return self._phenotype_observed
             else:
                 if self._missing_implies_phenotype_excluded:
                     return self._phenotype_excluded
                 else:
-                    if any(
-                        self._query == desc
-                        for desc in self._hpo.graph.get_descendants(
-                            phenotype, include_source=True
-                        )
+                    if phenotype.identifier == self._query or any(
+                        phenotype.identifier == anc
+                        for anc in self._hpo.graph.get_ancestors(self._query)
                     ):
                         return self._phenotype_excluded
 
@@ -220,21 +238,30 @@ class DiseasePresencePredicate(PhenotypePolyPredicate[hpotk.TermId]):
     """
 
     def __init__(self, disease_id_query: hpotk.TermId):
-        self._query = hpotk.util.validate_instance(
+        self._query = validate_instance(
             disease_id_query, hpotk.TermId, "disease_id_query"
         )
 
         self._diagnosis_present = PhenotypeCategorization(
-            category=PatientCategories.YES,
+            category=YES,
             phenotype=disease_id_query,
         )
         self._diagnosis_excluded = PhenotypeCategorization(
-            category=PatientCategories.NO,
+            category=NO,
             phenotype=disease_id_query,
         )
 
-    def get_question_base(self) -> str:
-        return f"Was {self._query} diagnosed in the patient"
+    @property
+    def name(self) -> str:
+        return "Disease Predicate"
+
+    @property
+    def description(self) -> str:
+        return f"Partition based on a diagnosis of {self._query.value}"
+
+    @property
+    def variable_name(self) -> str:
+        return self._query.value
 
     @property
     def phenotype(self) -> hpotk.TermId:
diff --git a/src/gpsea/analysis/pscore/_api.py b/src/gpsea/analysis/pscore/_api.py
index dc203347..61956ca9 100644
--- a/src/gpsea/analysis/pscore/_api.py
+++ b/src/gpsea/analysis/pscore/_api.py
@@ -7,8 +7,11 @@
 from ..predicate.genotype import GenotypePolyPredicate
 from .stats import PhenotypeScoreStatistic
 
+from .._base import MonoPhenotypeAnalysisResult, Statistic
+from .._partition import ContinuousPartitioning
 
-class PhenotypeScorer(metaclass=abc.ABCMeta):
+
+class PhenotypeScorer(ContinuousPartitioning, metaclass=abc.ABCMeta):
     """
     `PhenotypeScorer` assigns the patient with a phenotype score.
 
@@ -20,6 +23,7 @@ class PhenotypeScorer(metaclass=abc.ABCMeta):
     @staticmethod
     def wrap_scoring_function(
         func: typing.Callable[[Patient], float],
+        name: str = "Custom Scoring Function",
     ) -> "PhenotypeScorer":
         """
         Create a `PhenotypeScorer` by wrap the provided scoring function `func`.
@@ -38,7 +42,10 @@ def wrap_scoring_function(
 
         :param func: the scoring function.
         """
-        return FunctionPhenotypeScorer(func=func)
+        return FunctionPhenotypeScorer(
+            name=name,
+            func=func,
+        )
 
     @abc.abstractmethod
     def score(self, patient: Patient) -> float:
@@ -54,10 +61,25 @@ class FunctionPhenotypeScorer(PhenotypeScorer):
     """
     # NOT PART OF THE PUBLIC API
 
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @property
+    def description(self) -> str:
+        return "A custom function to compute the phenotype score"
+
+    @property
+    def variable_name(self) -> str:
+        return "Phenotype score"
+
     def __init__(
         self,
+        name: str,
         func: typing.Callable[[Patient], float],
     ):
+        assert isinstance(name, str)
+        self._name = name
         self._func = func
 
     def score(self, patient: Patient) -> float:
@@ -67,56 +89,55 @@ def score(self, patient: Patient) -> float:
         return self._func(patient)
 
 
-class PhenotypeScoreAnalysisResult:
+class PhenotypeScoreAnalysisResult(MonoPhenotypeAnalysisResult):
     """
     `PhenotypeScoreAnalysisResult` is a container for :class:`PhenotypeScoreAnalysis` results.
+
+    The :attr:`PhenotypeScoreAnalysisResult.data` property provides a data frame
+    with phenotype score for each tested individual:
+    
+    ==========  ========  =========
+    patient_id  genotype  phenotype
+    ==========  ========  =========
+    patient_1   0         1
+    patient_2   0         nan
+    patient_3   None      2
+    patient_4   1         2
+    ...         ...       ...
+    ==========  ========  ===============
+
+    The DataFrame index includes the identifiers of the tested individuals and the values are stored
+    in `genotype` and `phenotype` columns.
+
+    The `genotype` includes the genotype category ID (:attr:`~gpsea.analysis.predicate.PatientCategory.cat_id`)
+    or `None` if the patient cannot be assigned into any genotype category.
+    
+    The `phenotype` contains a `float` with the phenotype score. A `NaN` value is used
+    if the phenotype score is impossible to compute.
     """
 
     def __init__(
         self,
-        genotype_phenotype_scores: pd.DataFrame,
+        gt_predicate: GenotypePolyPredicate,
+        phenotype: PhenotypeScorer,
+        statistic: Statistic,
+        data: pd.DataFrame,
         pval: float,
     ):
-        self._genotype_phenotype_scores = genotype_phenotype_scores
-        if isinstance(pval, float) and 0. <= pval <= 1.:
-            self._pval = float(pval)
-        else:
-            raise ValueError(f"`p_val` must be a finite float in range [0, 1] but it was {pval}")
+        super().__init__(gt_predicate, phenotype, statistic, data, pval)
+        assert isinstance(phenotype, PhenotypeScorer)
 
-    @property
-    def genotype_phenotype_scores(self) -> pd.DataFrame:
-        """
-        Get the DataFrame with the genotype group and the phenotype score for each patient.
-
-        The DataFrame has the following structure:
-
-        ==========  ========  =========
-        patient_id  genotype  phenotype
-        ==========  ========  =========
-        patient_1   0         1
-        patient_2   0         3
-        patient_3   None      2
-        patient_4   1         2
-        ...         ...       ...
-        ==========  ========  =========
-
-        The DataFrame index includes the patient IDs, and then there are 2 columns
-        with the `genotype` group id (:attr:`~gpsea.analysis.predicate.PatientCategory.cat_id`)
-        and the `phenotype` score. A `genotype` value may be missing if the patient
-        cannot be assigned into any genotype category.
-        """
-        return self._genotype_phenotype_scores
-
-    @property
-    def pval(self) -> float:
+    def phenotype_scorer(self) -> PhenotypeScorer:
         """
-        Get the p value of the test.
+        Get the scorer that computed the phenotype score.
         """
-        return self._pval
+        # We are sure that `self._phenotype` is a `PhenotypeScorer`
+        # because of the instance check in `__init__` and `PhenotypeScorer`
+        # being a subclass of `Partitioning`.
+        return self._phenotype  # type: ignore
 
     def plot_boxplots(
         self,
-        gt_predicate: GenotypePolyPredicate,
         ax,
         colors=("darksalmon", "honeydew"),
     ):
@@ -128,23 +149,25 @@ def plot_boxplots(
         :param colors: a tuple with colors to use for coloring the box patches of the box plot.
         """
         # skip the patients with unassigned genotype group
-        bla = self._genotype_phenotype_scores.notna()
+        bla = self._data.notna()
         not_na_gts = bla.all(axis='columns')
-        data = self._genotype_phenotype_scores.loc[not_na_gts]
+        data = self._data.loc[not_na_gts]
         
         # Check that the provided genotype predicate defines the same categories
         # as those found in `data.`
-        actual = set(data["genotype"].unique())
-        expected = set(c.cat_id for c in gt_predicate.get_categories())
+        actual = set(data[MonoPhenotypeAnalysisResult.GT_COL].unique())
+        expected = set(c.cat_id for c in self._gt_predicate.get_categories())
         assert actual == expected, 'Mismatch in the genotype categories'
         
         x = [
-            data.loc[data["genotype"] == c.category.cat_id, "phenotype"].to_list()
-            for c in gt_predicate.get_categorizations()
+            data.loc[
+                data[MonoPhenotypeAnalysisResult.GT_COL] == c.category.cat_id, MonoPhenotypeAnalysisResult.PH_COL
+            ].to_list()
+            for c in self._gt_predicate.get_categorizations()
         ]
         
         gt_cat_names = [
-            c.category.name for c in gt_predicate.get_categorizations()
+            c.category.name for c in self._gt_predicate.get_categorizations()
         ]
         bplot = ax.boxplot(
             x=x,
@@ -155,6 +178,26 @@ def plot_boxplots(
         for patch, color in zip(bplot["boxes"], colors):
             patch.set_facecolor(color)
 
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, PhenotypeScoreAnalysisResult) \
+            and super(MonoPhenotypeAnalysisResult, self).__eq__(value)
+    
+    def __hash__(self) -> int:
+        return super(MonoPhenotypeAnalysisResult, self).__hash__()
+
+    def __str__(self) -> str:
+        return (
+            "PhenotypeScoreAnalysisResult("
+            f"gt_predicate={self._gt_predicate}, "
+            f"phenotype_scorer={self._phenotype}, "
+            f"statistic={self._statistic}, "
+            f"data={self._data}, "
+            f"pval={self._pval})"
+        )
+
+    def __repr__(self) -> str:
+        return str(self)
+
 
 class PhenotypeScoreAnalysis:
     """
@@ -191,32 +234,40 @@ def compare_genotype_vs_phenotype_score(
         assert (
             gt_predicate.n_categorizations() == 2
         ), "We only support 2 genotype categories at this point"
+        assert isinstance(pheno_scorer, PhenotypeScorer)
 
         idx = pd.Index((patient.patient_id for patient in cohort), name="patient_id")
         data = pd.DataFrame(
             None,
             index=idx,
-            columns=["genotype", "phenotype"],
+            columns=MonoPhenotypeAnalysisResult.DATA_COLUMNS,
         )
 
         # Apply the predicates on the patients
         for patient in cohort:
             gt_cat = gt_predicate.test(patient)
             if gt_cat is None:
-                data.loc[patient.patient_id, "genotype"] = None
+                data.loc[patient.patient_id, MonoPhenotypeAnalysisResult.GT_COL] = None
             else:
-                data.loc[patient.patient_id, "genotype"] = gt_cat.category.cat_id
+                data.loc[patient.patient_id, MonoPhenotypeAnalysisResult.GT_COL] = gt_cat.category.cat_id
             
-            data.loc[patient.patient_id, "phenotype"] = pheno_scorer.score(patient)
+            data.loc[patient.patient_id, MonoPhenotypeAnalysisResult.PH_COL] = pheno_scorer.score(patient)
 
         # Sort by PatientCategory.cat_id and unpack.
         # For now, we only allow to have up to 2 groups.
-        x_key, y_key = sorted(data["genotype"].dropna().unique())
-        x = data.loc[data["genotype"] == x_key, "phenotype"].to_numpy(dtype=float)  # type: ignore
-        y = data.loc[data["genotype"] == y_key, "phenotype"].to_numpy(dtype=float)  # type: ignore
+        x_key, y_key = sorted(data[MonoPhenotypeAnalysisResult.GT_COL].dropna().unique())
+        x = data.loc[
+            data[MonoPhenotypeAnalysisResult.GT_COL] == x_key, MonoPhenotypeAnalysisResult.PH_COL
+        ].to_numpy(dtype=float)  # type: ignore
+        y = data.loc[
+            data[MonoPhenotypeAnalysisResult.GT_COL] == y_key, MonoPhenotypeAnalysisResult.PH_COL
+        ].to_numpy(dtype=float)  # type: ignore
         pval = self._statistic.compute_pval(scores=(x, y))
 
         return PhenotypeScoreAnalysisResult(
-            genotype_phenotype_scores=data,
+            gt_predicate=gt_predicate,
+            phenotype=pheno_scorer,
+            statistic=self._statistic,
+            data=data,
             pval=pval,
         )
diff --git a/src/gpsea/analysis/pscore/_hpo.py b/src/gpsea/analysis/pscore/_hpo.py
index e949ad6c..6187b0ea 100644
--- a/src/gpsea/analysis/pscore/_hpo.py
+++ b/src/gpsea/analysis/pscore/_hpo.py
@@ -24,8 +24,8 @@ class CountingPhenotypeScorer(PhenotypeScorer):
 
     @staticmethod
     def from_query_curies(
-            hpo: hpotk.MinimalOntology,
-            query: typing.Iterable[typing.Union[str, hpotk.TermId]],
+        hpo: hpotk.MinimalOntology,
+        query: typing.Iterable[typing.Union[str, hpotk.TermId]],
     ):
         """
         Create a scorer to test for the number of phenotype terms that fall into the phenotype groups.
@@ -76,8 +76,21 @@ def __init__(
         self._hpo = hpo
         self._query = set(query)
 
-    def get_question(self) -> str:
-        return "How many of the query HPO terms (or their descendants) does the individual display"
+    @property
+    def name(self) -> str:
+        return "HPO Group Count"
+
+    @property
+    def description(self) -> str:
+        return (
+            "Assign a phenotype score that is equivalent to the count "
+            "of present phenotypes that are either an exact match to "
+            "the query terms or their descendants"
+        )
+
+    @property
+    def variable_name(self) -> str:
+        return "HPO group count"
 
     def score(
             self,
@@ -133,6 +146,20 @@ def __init__(
             'HP:0006889': 0.5,
         }
 
+    @property
+    def name(self) -> str:
+        return "De Vries Score"
+
+    @property
+    def description(self) -> str:
+        return (
+            "A phenotypic severity score for individuals with intellectual disability"
+        )
+
+    @property
+    def variable_name(self) -> str:
+        return "De Vries score"
+
     def _developmental_delay_score(
         self,
         observed_term_ids: typing.Iterable[str],
diff --git a/src/gpsea/analysis/pscore/_measurement.py b/src/gpsea/analysis/pscore/_measurement.py
index d2c45030..104853de 100644
--- a/src/gpsea/analysis/pscore/_measurement.py
+++ b/src/gpsea/analysis/pscore/_measurement.py
@@ -21,38 +21,66 @@ class MeasurementPhenotypeScorer(PhenotypeScorer):
     LOINC code as a phenotype score.
 
     >>> from gpsea.analysis.pscore import MeasurementPhenotypeScorer
-    >>> pheno_scorer = MeasurementPhenotypeScorer.from_measurement_id("LOINC:2986-8")
+    >>> pheno_scorer = MeasurementPhenotypeScorer.from_measurement_id(
+    ...     term_id="LOINC:2986-8",
+    ...     label="Testosterone [Mass/volume] in Serum or Plasma",
+    ... )
     >>> # use the scorer in the analysis ...
     """
 
     @staticmethod
     def from_measurement_id(
         term_id: typing.Union[str, hpotk.TermId],
+        label: str,
     ) -> "MeasurementPhenotypeScorer":
         """
         Create `MeasurementPhenotypeScorer` from a measurement identifier.
 
         :param term_id: a `str` with CURIE or a :class:`~hpotk.TermId`
             representing the term ID of a measurement (e.g. `LOINC:2986-8`).
+        :param label: a `str` with the measurement label (e.g. `Testosterone [Mass/volume] in Serum or Plasma`)
         """
+        return MeasurementPhenotypeScorer(
+            term_id=term_id,
+            label=label,
+        )
+
+    def __init__(
+        self,
+        term_id: typing.Union[str, hpotk.TermId],
+        label: str,
+    ):
         if isinstance(term_id, str):
-            term_id = hpotk.TermId.from_curie(term_id)
+            self._identifier = hpotk.TermId.from_curie(term_id)
         elif isinstance(term_id, hpotk.TermId):
-            pass
+            self._identifier = term_id
         else:
             raise ValueError(
                 f"`term_id` must be a `str` or a `hpotk.TermId` but was {type(term_id)}"
             )
+        
+        assert isinstance(label, str)
+        self._label = label
 
-        return MeasurementPhenotypeScorer(
-            identifier=term_id,
-        )
+    @property
+    def name(self) -> str:
+        return "Measurement"
 
-    def __init__(
-        self,
-        identifier: hpotk.TermId,
-    ):
-        self._identifier = identifier
+    @property
+    def description(self) -> str:
+        return f"Value of {self._label} [{self._identifier.value}]"
+
+    @property
+    def variable_name(self) -> str:
+        return self._identifier.value
+
+    @property
+    def term_id(self) -> hpotk.TermId:
+        return self._identifier
+    
+    @property
+    def label(self) -> str:
+        return self._label
 
     def score(
         self,
@@ -71,13 +99,14 @@ def __eq__(self, value: object) -> bool:
         return (
             isinstance(value, MeasurementPhenotypeScorer)
             and self._identifier == value._identifier
+            and self._label == value._label
         )
 
     def __hash__(self) -> int:
-        return hash((self._identifier,))
+        return hash((self._identifier, self._label))
 
     def __str__(self) -> str:
-        return f"MeasurementPhenotypeScorer(term_id={self._identifier})"
+        return f"MeasurementPhenotypeScorer(term_id={self._identifier}, label={self._label})"
 
     def __repr__(self) -> str:
         return str(self)
diff --git a/src/gpsea/analysis/pscore/stats/_stats.py b/src/gpsea/analysis/pscore/stats/_stats.py
index f934e3ea..e47e287a 100644
--- a/src/gpsea/analysis/pscore/stats/_stats.py
+++ b/src/gpsea/analysis/pscore/stats/_stats.py
@@ -4,8 +4,10 @@
 
 from scipy.stats import mannwhitneyu, ttest_ind
 
+from ..._base import Statistic
 
-class PhenotypeScoreStatistic(metaclass=abc.ABCMeta):
+
+class PhenotypeScoreStatistic(Statistic, metaclass=abc.ABCMeta):
     """
     `PhenotypeScoreStatistic` calculates a p value
     for 2 or more phenotype score groups
@@ -19,6 +21,12 @@ def compute_pval(
     ) -> float:
         pass
 
+    def __eq__(self, value: object) -> bool:
+        return super().__eq__(value)
+    
+    def __hash__(self) -> int:
+        return super().__hash__()
+
 
 class MannWhitneyStatistic(PhenotypeScoreStatistic):
     """
@@ -31,6 +39,11 @@ class MannWhitneyStatistic(PhenotypeScoreStatistic):
     See :ref:`phenotype-score-stats` for an example usage.
     """
     
+    def __init__(self):
+        super().__init__(
+            name="Mann-Whitney U test",
+        )
+
     def compute_pval(
         self,
         scores: typing.Collection[typing.Sequence[float]],
@@ -54,6 +67,12 @@ def _remove_nans(
     ) -> typing.Sequence[float]:
         return tuple(val for val in a if not math.isnan(val))
 
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, MannWhitneyStatistic)
+    
+    def __hash__(self) -> int:
+        return 23
+
 
 class TTestStatistic(PhenotypeScoreStatistic):
     """
@@ -64,6 +83,11 @@ class TTestStatistic(PhenotypeScoreStatistic):
     The `NaN` phenotype score values are ignored.
     """
 
+    def __init__(self):
+        super().__init__(
+            name="Student's t-test",
+        )
+
     # TODO: refer to a user guide example to show a usage example.
 
     def compute_pval(
@@ -80,3 +104,9 @@ def compute_pval(
         )
 
         return res.pvalue
+
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, TTestStatistic)
+    
+    def __hash__(self) -> int:
+        return 31
diff --git a/src/gpsea/analysis/temporal/_api.py b/src/gpsea/analysis/temporal/_api.py
index 6d00841c..6a739c3b 100644
--- a/src/gpsea/analysis/temporal/_api.py
+++ b/src/gpsea/analysis/temporal/_api.py
@@ -1,19 +1,23 @@
 import abc
-import math
 import typing
 
 from collections import defaultdict
 
 import pandas as pd
+import scipy.stats
 
 from gpsea.model import Patient
 from ..predicate.genotype import GenotypePolyPredicate
 
 from ._base import Survival
+from ._util import prepare_censored_data
 from .stats import SurvivalStatistic
 
+from .._base import MonoPhenotypeAnalysisResult
+from .._partition import ContinuousPartitioning
 
-class Endpoint(metaclass=abc.ABCMeta):
+
+class Endpoint(ContinuousPartitioning, metaclass=abc.ABCMeta):
     """
     `Endpoint` computes survival for the analyzed individual.
 
@@ -33,106 +37,101 @@ def compute_survival(
         """
         pass
 
-    @abc.abstractmethod
-    def display_question(self) -> str:
-        pass
-
 
-class SurvivalAnalysisResult:
+class SurvivalAnalysisResult(MonoPhenotypeAnalysisResult):
     """
     `SurvivalAnalysisResult` includes the results of a :class:`~gpsea.analysis.temporal.SurvivalAnalysis`.
+
+    The genotype categories and survival are reported in the `data` data frame with the following structure:
+
+    ============  ===========  ============================================
+     patient_id    genotype     phenotype
+    ============  ===========  ============================================
+     patient_1     0            `Survival(value=123.4, is_censored=False)`
+     patient_2     0            `None`
+     patient_3     `None`       `Survival(value=456.7, is_censored=True)`
+     patient_4     1            `None`
+     ...           ...          ...
+    ============  ===========  ============================================
+
+    The index includes the individual IDs (`patient_id`), and then there are 2 columns
+    with the `genotype` group id (:attr:`~gpsea.analysis.predicate.PatientCategory.cat_id`)
+    and the `phenotype` with the survival represented as :class:`~gpsea.analysis.tempo.Survival` object.
+
+    A `genotype` value may be missing (`None`) if the individual cannot be assigned
+    into a genotype category.
+    Similarly, a `survival` is `None` if computing the survival for an individual is impossible.
     """
 
     def __init__(
         self,
         gt_predicate: GenotypePolyPredicate,
+        endpoint: Endpoint,
+        statistic: SurvivalStatistic,
         data: pd.DataFrame,
         pval: float,
     ):
-        assert isinstance(gt_predicate, GenotypePolyPredicate)
-        self._gt_predicate = gt_predicate
-
-        assert isinstance(data, pd.DataFrame) and all(
-            col in data for col in ("genotype", "survival")
+        super().__init__(
+            gt_predicate=gt_predicate,
+            phenotype=endpoint,
+            statistic=statistic,
+            data=data,
+            pval=pval,
         )
-        self._data = data
-
-        if isinstance(pval, float) and math.isfinite(pval) and 0.0 <= pval <= 1.0:
-            self._pval = float(pval)
-        else:
-            raise ValueError(
-                f"`p_val` must be a finite float in range [0, 1] but it was {pval}"
-            )
+        assert isinstance(endpoint, Endpoint)
 
     @property
-    def gt_predicate(self) -> GenotypePolyPredicate:
+    def endpoint(self) -> Endpoint:
         """
-        Get the genotype predicate used in the survival analysis that produced this result.
+        Get the endpoint used to compute the survival of the individuals.
         """
-        return self._gt_predicate
+        # We are sure that `self._phenotype` is assignable to `Endpoint`
+        # because of the instance check in `__init__` and `Endpoint`
+        # being a subclass of `Partitioning`.
+        return self._phenotype  # type: ignore
 
-    @property
-    def data(self) -> pd.DataFrame:
-        """
-        Get the data frame with the genotype group
-        and the corresponding :class:`~gpsea.analysis.tempo.Survival`.
-
-        The DataFrame has the following structure:
-
-        ==========  ==========  ============================================
-        patient_id   genotype    survival
-        ==========  ==========  ============================================
-        patient_1   0            `Survival(value=123.4, is_censored=False)`
-        patient_2   0            `None`
-        patient_3   `None`       `Survival(value=456.7, is_censored=True)`
-        patient_4   1            `None`
-        ...         ...          ...
-        ==========  ==========  ============================================
-
-        The index includes the individual IDs (`patient_id`), and then there are 2 columns
-        with the `genotype` group id (:attr:`~gpsea.analysis.predicate.PatientCategory.cat_id`)
-        and the `survival` encoded as :class:`~gpsea.analysis.tempo.Survival` object.
-
-        A `genotype` value may be missing (`None`) if the individual cannot be assigned
-        into a genotype category.
-        Similarly, a `survival` may be `None` if computing the survival is impossible for
-        the individual in question.
+    def plot_kaplan_meier_curves(
+        self,
+        ax,
+    ):
         """
-        return self._data
+        Plot genotype group survivals on the provided axes.
 
-    def complete_records(self) -> pd.DataFrame:
-        """
-        Get the :meth:`~gpsea.analysis.temporal.SurvivalAnalysisResult.data` rows
-        where both `genotype` and `survival` columns are available (i.e. not `None`).
-        """
-        return self._data.loc[
-            self._data["genotype"].notna() & self._data["survival"].notna()
-        ]
+        The axes includes legend. However, if no survival is available
+        for a genotype group, the group name will be missing from the legend.
 
-    @property
-    def pval(self) -> float:
+        :param ax: a Matplotlib `Axes` to draw on.
         """
-        Get the p value of the test.
-        """
-        return self._pval
+        for pat_cat in self._gt_predicate.get_categories():
+            survivals = self._data.loc[
+                self._data[MonoPhenotypeAnalysisResult.GT_COL] == pat_cat.cat_id,
+                MonoPhenotypeAnalysisResult.PH_COL,
+            ]
+            non_na = survivals[survivals.notna()]
+            if len(non_na) > 0:
+                censored_data = prepare_censored_data(survivals=non_na)
+                data = scipy.stats.ecdf(censored_data)
+                data.sf.plot(ax, label=pat_cat.name)
+        
+        ax.legend()
 
     def __eq__(self, value: object) -> bool:
         return (
             isinstance(value, SurvivalAnalysisResult)
-            and self._gt_predicate == value._gt_predicate
-            and self._data.equals(value._data)
-            and self._pval == value._pval
+            and super(MonoPhenotypeAnalysisResult, self).__eq__(value)
         )
 
     def __hash__(self) -> int:
-        return hash((self._gt_predicate, self._data, self._pval))
+        return super(MonoPhenotypeAnalysisResult, self).__hash__()
 
     def __str__(self) -> str:
         return (
             "SurvivalAnalysisResult("
-            "gt_predicate={self._gt_predicate}, "
-            "data={self._data}, "
-            "pval={self._pval})"
+            f"gt_predicate={self._gt_predicate}, "
+            f"endpoint={self._phenotype}, "
+            f"statistic={self._statistic}, "
+            f"data={self._data}, "
+            f"pval={self._pval})"
         )
 
     def __repr__(self) -> str:
@@ -170,19 +169,19 @@ def compare_genotype_vs_survival(
         data = pd.DataFrame(
             None,
             index=idx,
-            columns=["genotype", "survival"],
+            columns=MonoPhenotypeAnalysisResult.DATA_COLUMNS,
         )
         survivals = defaultdict(list)
         # Apply the predicate and the survival metric on the cohort
         for patient in cohort:
             gt_cat = gt_predicate.test(patient)
             if gt_cat is None:
-                data.loc[patient.patient_id, "genotype"] = None
+                data.loc[patient.patient_id, MonoPhenotypeAnalysisResult.GT_COL] = None
             else:
-                data.loc[patient.patient_id, "genotype"] = gt_cat.category.cat_id
+                data.loc[patient.patient_id, MonoPhenotypeAnalysisResult.GT_COL] = gt_cat.category.cat_id
 
             survival = endpoint.compute_survival(patient)
-            data.loc[patient.patient_id, "survival"] = survival  # type: ignore
+            data.loc[patient.patient_id, MonoPhenotypeAnalysisResult.PH_COL] = survival  # type: ignore
 
             if gt_cat is not None and survival is not None:
                 survivals[gt_cat].append(survival)
@@ -192,6 +191,8 @@ def compare_genotype_vs_survival(
 
         return SurvivalAnalysisResult(
             gt_predicate=gt_predicate,
+            endpoint=endpoint,
+            statistic=self._statistic,
             data=data,
             pval=pval,
         )
diff --git a/src/gpsea/analysis/temporal/_util.py b/src/gpsea/analysis/temporal/_util.py
new file mode 100644
index 00000000..b8ce0a45
--- /dev/null
+++ b/src/gpsea/analysis/temporal/_util.py
@@ -0,0 +1,21 @@
+import typing
+
+from scipy.stats import CensoredData
+
+from ._api import Survival
+
+
+def prepare_censored_data(
+    survivals: typing.Iterable[Survival],
+) -> CensoredData:
+    uncensored = []
+    right_censored = []
+    for survival in survivals:
+        if survival.is_censored:
+            right_censored.append(survival.value)
+        else:
+            uncensored.append(survival.value)
+    return CensoredData(
+        uncensored=uncensored,
+        right=right_censored,
+    )
diff --git a/src/gpsea/analysis/temporal/endpoint/_impl.py b/src/gpsea/analysis/temporal/endpoint/_impl.py
index 4d10cadf..cffe0eab 100644
--- a/src/gpsea/analysis/temporal/endpoint/_impl.py
+++ b/src/gpsea/analysis/temporal/endpoint/_impl.py
@@ -32,6 +32,18 @@ def _compute_survival(
 
 class Death(EndpointBase):
 
+    @property
+    def name(self) -> str:
+        return "Age of death"
+
+    @property
+    def description(self) -> str:
+        return f"Compute time until {self._timeline.name.lower()} death"
+
+    @property
+    def variable_name(self) -> str:
+        return "Age of death"
+
     def compute_survival(
         self,
         patient: Patient,
@@ -55,8 +67,8 @@ def compute_survival(
                 is_censored=True,
             )
         
-    def display_question(self) -> str:
-        return f"Compute time until {self._timeline.name.lower()} death"
+    def question_base(self) -> str:
+        return f"time until {self._timeline.name.lower()} death"
 
     def __eq__(self, value: object) -> bool:
         return isinstance(value, Death) and self._timeline == value._timeline
@@ -89,6 +101,18 @@ def __init__(
 
         assert term_id in hpo, f"`term_id` {term_id.value} is not in HPO {hpo.version}"
 
+    @property
+    def name(self) -> str:
+        return f"Onset of {self._hpo.get_term_name(self._term_id)}"
+
+    @property
+    def description(self) -> str:
+        return f"Compute time until onset of {self._hpo.get_term_name(self._term_id)}"
+
+    @property
+    def variable_name(self) -> str:
+        return f"Onset of {self._term_id.value}"
+
     def compute_survival(
         self,
         patient: Patient,
@@ -124,10 +148,6 @@ def compute_survival(
                 age=earliest_onset,
                 is_censored=False,
             )
-            
-    def display_question(self) -> str:
-        label = self._hpo.get_term_name(self._term_id)
-        return f"Compute time until {self._timeline.name.lower()} onset of {label}"
 
     def __eq__(self, value: object) -> bool:
         return (
@@ -164,6 +184,18 @@ def __init__(
         assert isinstance(disease_id, hpotk.TermId)
         self._disease_id = disease_id
 
+    @property
+    def name(self) -> str:
+        return f"Onset of {self._disease_id.value}"
+
+    @property
+    def description(self) -> str:
+        return f"Compute time until {self._disease_id.value} onset"
+
+    @property
+    def variable_name(self) -> str:
+        return f"Onset of {self._disease_id.value}"
+
     def compute_survival(
         self,
         patient: Patient,
@@ -180,8 +212,8 @@ def compute_survival(
             is_censored=True,
         )
         
-    def display_question(self) -> str:
-        return f"Compute time until {self._timeline.name.lower()} diagnosis of {self._disease_id.value}"
+    def question_base(self) -> str:
+        return f"time until {self._timeline.name.lower()} diagnosis of {self._disease_id.value}"
 
     def __eq__(self, value: object) -> bool:
         return (
diff --git a/src/gpsea/analysis/temporal/stats/_api.py b/src/gpsea/analysis/temporal/stats/_api.py
index 79ef01d9..b80f5053 100644
--- a/src/gpsea/analysis/temporal/stats/_api.py
+++ b/src/gpsea/analysis/temporal/stats/_api.py
@@ -1,16 +1,20 @@
 import abc
 import typing
 
+from ..._base import Statistic
 from .._base import Survival
 
 
-class SurvivalStatistic(metaclass=abc.ABCMeta):
+class SurvivalStatistic(Statistic, metaclass=abc.ABCMeta):
     """
     `SurvivalStatistic` calculates a p value
     for 2 or more survival groups
     computed by a :class:`~gpsea.analysis.tempo.SurvivalMetric`.
     """
 
+    def __init__(self, name: str):
+        super().__init__(name)
+
     @abc.abstractmethod
     def compute_pval(
         self,
@@ -21,3 +25,9 @@ def compute_pval(
         the same source distribution.
         """
         pass
+
+    def __eq__(self, value: object) -> bool:
+        return super().__eq__(value)
+    
+    def __hash__(self) -> int:
+        return super().__hash__()
diff --git a/src/gpsea/analysis/temporal/stats/_impl.py b/src/gpsea/analysis/temporal/stats/_impl.py
index 814df5d0..b287fb19 100644
--- a/src/gpsea/analysis/temporal/stats/_impl.py
+++ b/src/gpsea/analysis/temporal/stats/_impl.py
@@ -3,6 +3,7 @@
 from scipy import stats
 
 from .._base import Survival
+from .._util import prepare_censored_data
 from ._api import SurvivalStatistic
 
 
@@ -14,6 +15,11 @@ class LogRankTest(SurvivalStatistic):
     A two-sided alternative hypothesis is tested.
     """
 
+    def __init__(self):
+        super().__init__(
+            name="Logrank test",
+        )
+
     def compute_pval(
         self,
         scores: typing.Collection[typing.Iterable[Survival]],
@@ -23,11 +29,11 @@ def compute_pval(
 
         :param scores: a pair of survival groups
         """
-        assert len(scores) == 2, "Log rank test only supports 2 groups at this time"
+        assert len(scores) == 2, "Logrank test only supports 2 groups at this time"
         x, y = tuple(scores)
 
-        xc = LogRankTest._prepare_censored_data(x)
-        yc = LogRankTest._prepare_censored_data(y)
+        xc = prepare_censored_data(x)
+        yc = prepare_censored_data(y)
 
         result = stats.logrank(
             x=xc,
@@ -37,18 +43,8 @@ def compute_pval(
 
         return float(result.pvalue)
 
-    @staticmethod
-    def _prepare_censored_data(
-        survivals: typing.Iterable[Survival],
-    ) -> stats.CensoredData:
-        uncensored = []
-        right_censored = []
-        for survival in survivals:
-            if survival.is_censored:
-                right_censored.append(survival.value)
-            else:
-                uncensored.append(survival.value)
-        return stats.CensoredData(
-            uncensored=uncensored,
-            right=right_censored,
-        )
+    def __eq__(self, value: object) -> bool:
+        return isinstance(value, LogRankTest)
+    
+    def __hash__(self) -> int:
+        return 37
diff --git a/src/gpsea/model/_cohort.py b/src/gpsea/model/_cohort.py
index 2483b0b2..e6eecf3b 100644
--- a/src/gpsea/model/_cohort.py
+++ b/src/gpsea/model/_cohort.py
@@ -2,7 +2,7 @@
 import itertools
 import typing
 
-from collections import Counter
+from collections import Counter, defaultdict
 from dataclasses import dataclass
 
 import hpotk
@@ -10,7 +10,7 @@
 from ._base import SampleLabels, Sex
 from ._phenotype import Phenotype, Disease, Measurement
 from ._temporal import Age
-from ._variant import Variant
+from ._variant import Variant, VariantInfo
 
 
 class Status(enum.Enum):
@@ -27,11 +27,11 @@ class VitalStatus:
     @property
     def is_alive(self) -> bool:
         return self.status == Status.ALIVE
-    
+
     @property
     def is_deceased(self) -> bool:
         return self.status == Status.DECEASED
-    
+
     @property
     def is_unknown(self) -> bool:
         return self.status == Status.UNKNOWN
@@ -55,7 +55,7 @@ class Patient:
     * genotype information as one or more :class:`~gpsea.model.Variant`
 
     .. note::
-    
+
         We strongly recommend using the :func:`from_raw_parts` static constructor
         instead of `__init__`.
     """
@@ -79,7 +79,7 @@ def from_raw_parts(
 
         if sex is None:
             sex = Sex.UNKNOWN_SEX
-        
+
         return Patient(
             labels=labels,
             sex=sex,
@@ -104,14 +104,14 @@ def __init__(
     ):
         assert isinstance(labels, SampleLabels)
         self._labels = labels
-        
+
         assert isinstance(sex, Sex)
         self._sex = sex
-        
+
         if age is not None:
             assert isinstance(age, Age)
         self._age = age
-        
+
         if vital_status is not None:
             assert isinstance(vital_status, VitalStatus)
         self._vital_status = vital_status
@@ -148,7 +148,7 @@ def age(self) -> typing.Optional[Age]:
         Get age of the individual or `None` if not available.
         """
         return self._age
-    
+
     @property
     def vital_status(self) -> typing.Optional[VitalStatus]:
         """
@@ -162,14 +162,14 @@ def phenotypes(self) -> typing.Sequence[Phenotype]:
         Get the phenotypes observed and excluded in the patient.
         """
         return self._phenotypes
-    
+
     @property
     def measurements(self) -> typing.Sequence[Measurement]:
         """
         Get the measurements in the patient.
         """
         return self._measurements
-    
+
     def measurement_by_id(
         self,
         term_id: typing.Union[str, hpotk.TermId],
@@ -191,7 +191,7 @@ def measurement_by_id(
         for m in self._measurements:
             if m.identifier.value == term_id:
                 return m
-        
+
         return None
 
     @property
@@ -267,12 +267,16 @@ def __hash__(self) -> int:
 
 
 class Cohort(typing.Sized, typing.Iterable[Patient]):
+    """
+    Cohort is a collection of individuals that have been preprocessed
+    and are ready for genotype-phenotype association analysis.
+    """
 
     @staticmethod
     def from_patients(
-            members: typing.Iterable[Patient],
-            include_patients_with_no_HPO: bool = False,
-            include_patients_with_no_variants: bool = False,
+        members: typing.Iterable[Patient],
+        include_patients_with_no_HPO: bool = False,
+        include_patients_with_no_variants: bool = False,
     ):
         """
         Create a cohort from a sequence of patients.
@@ -316,6 +320,12 @@ def all_phenotypes(self) -> typing.Set[Phenotype]:
         """
         return set(itertools.chain(phenotype for patient in self._members for phenotype in patient.phenotypes))
 
+    def all_measurements(self) -> typing.Set[Measurement]:
+        """
+        Get a set of all phenotypes (observed or excluded) in the cohort members.
+        """
+        return set(itertools.chain(measurement for patient in self._members for measurement in patient.measurements))
+
     def all_diseases(self) -> typing.Set[Disease]:
         """
         Get a set of all diseases (observed or excluded) in the cohort members.
@@ -328,6 +338,14 @@ def all_variants(self) -> typing.Set[Variant]:
         """
         return set(itertools.chain(variant for patient in self._members for variant in patient.variants))
 
+    def all_variant_infos(self) -> typing.Set[VariantInfo]:
+        """
+        Get a set of variant infos observed in the cohort members.
+        """
+        return set(
+            itertools.chain(variant.variant_info for individual in self._members for variant in individual.variants)
+        )
+
     @property
     def all_transcript_ids(self) -> typing.Set[str]:
         """
@@ -354,11 +372,11 @@ def list_present_phenotypes(
     ) -> typing.Sequence[typing.Tuple[str, int]]:
         """
         Get a sequence with counts of HPO terms used as direct annotations of the cohort members.
-        
+
         Args:
             typing.Optional[int]: If not given, lists all present phenotypes.
                 Otherwise, lists only the `top` highest counts
-        
+
         Returns:
             typing.Sequence[typing.Tuple[str, int]]: A sequence of tuples, formatted (phenotype CURIE,
                 number of patients with that phenotype)
@@ -368,26 +386,48 @@ def list_present_phenotypes(
             counter.update(p.identifier.value for p in patient.phenotypes if p.is_present)
         return counter.most_common(top)
 
+    def list_measurements(
+        self,
+        top: typing.Optional[int] = None,
+    ) -> typing.Sequence[typing.Tuple[str, int]]:
+        """
+        Get a sequence with counts of HPO terms used as direct annotations of the cohort members.
+
+        Args:
+            typing.Optional[int]: If not given, lists all present phenotypes.
+                Otherwise, lists only the `top` highest counts
+
+        Returns:
+            typing.Sequence[typing.Tuple[str, int]]: A sequence of tuples, formatted (phenotype CURIE,
+                number of patients with that phenotype)
+        """
+        counter = Counter()
+        for patient in self._members:
+            counter.update(m.identifier.value for m in patient.measurements)
+        return counter.most_common(top)
+
     def list_all_diseases(
-            self,
-            top=None,
-    ) -> typing.Sequence[typing.Tuple[hpotk.TermId, int]]:
+        self,
+        top=None,
+    ) -> typing.Sequence[typing.Tuple[str, int]]:
         counter = Counter()
         for patient in self._members:
-            counter.update(d.identifier for d in patient.diseases)
+            counter.update(d.identifier.value for d in patient.diseases)
         return counter.most_common(top)
 
     def list_all_variants(
-            self,
-            top=None,
+        self,
+        top=None,
     ) -> typing.Sequence[typing.Tuple[str, int]]:
         """
         Args:
             typing.Optional[int]: If not given, lists all variants. Otherwise, lists only the `top` highest counts
-        
+
         Returns:
             list: A sequence of tuples, formatted (variant key, number of patients with that variant)
         """
+        # TODO: the counter counts the number of occurrences of a variant in an individual,
+        # and NOT the allele count! Evaluate if this is what we want!
         counter = Counter()
         for patient in self._members:
             counter.update(variant.variant_info.variant_key for variant in patient.variants)
@@ -400,7 +440,7 @@ def list_all_proteins(
         """
         Args:
             typing.Optional[int]: If not given, lists all proteins. Otherwise, lists only the `top` highest counts.
-        
+
         Returns:
             list: A list of tuples, formatted (protein ID string, the count of variants that affect the protein)
         """
@@ -419,25 +459,23 @@ def variant_effect_count_by_tx(
         Args:
             tx_id: a `str` with transcript accession (e.g. `NM_123456.5`)
               or `None` if all transcripts should be listed.
-        
+
         Returns:
-            typing.Mapping[str, typing.Mapping[str, int]]: Each transcript ID references a Counter(), with the variant effect as the key
-              and the count of variants with that effect on the transcript id.
+            typing.Mapping[str, typing.Mapping[str, int]]: Each transcript ID references a Counter(),
+              with the variant effect as the key and the count of variants with that effect on the transcript id.
         """
-        counters = {}
+        counters = defaultdict(Counter)
 
         for v in self.all_variants():
             for txa in v.tx_annotations:
                 if tx_id is None or tx_id == txa.transcript_id:
-                    if txa.transcript_id not in counters.keys():
-                        counters[txa.transcript_id] = Counter()
                     counters[txa.transcript_id].update(ve.name for ve in txa.variant_effects)
 
         return counters
 
     def get_excluded_count(self) -> int:
         return self._excluded_count
-    
+
     def get_variant_by_key(self, variant_key) -> Variant:
         for v in self.all_variants():
             if v.variant_info.variant_key == variant_key:
diff --git a/src/gpsea/preprocessing/__init__.py b/src/gpsea/preprocessing/__init__.py
index 77be3f5f..3c85d3e6 100644
--- a/src/gpsea/preprocessing/__init__.py
+++ b/src/gpsea/preprocessing/__init__.py
@@ -1,7 +1,6 @@
 from ._api import PreprocessingValidationResult
 from ._api import TranscriptCoordinateService, GeneCoordinateService
 from ._api import VariantCoordinateFinder, FunctionalAnnotator, ImpreciseSvFunctionalAnnotator, ProteinMetadataService
-from ._audit import Auditor, DataSanityIssue, Level, Notepad, NotepadTree
 from ._config import load_phenopacket_folder, load_phenopacket_files, load_phenopackets
 from ._config import configure_caching_cohort_creator, configure_cohort_creator
 from ._config import configure_default_protein_metadata_service, configure_protein_metadata_service
@@ -27,6 +26,5 @@
     'UniprotProteinMetadataService',
     'VepFunctionalAnnotator', 'VariantAnnotationCache', 'VarCachingFunctionalAnnotator',
     'VVHgvsVariantCoordinateFinder', 'VVMultiCoordinateService',
-    'Auditor', 'DataSanityIssue', 'Level', 'Notepad', 'NotepadTree',
     'DefaultImpreciseSvFunctionalAnnotator',
 ]
diff --git a/src/gpsea/preprocessing/_api.py b/src/gpsea/preprocessing/_api.py
index f274cb81..fa7a2a7c 100644
--- a/src/gpsea/preprocessing/_api.py
+++ b/src/gpsea/preprocessing/_api.py
@@ -1,9 +1,10 @@
 import abc
-import io
 import os
 import sys
 import typing
 
+from stairval.notepad import Notepad
+
 from gpsea.model import (
     VariantCoordinates,
     ProteinMetadata,
@@ -13,8 +14,6 @@
     ImpreciseSvInfo,
 )
 
-from ._audit import NotepadTree
-
 T = typing.TypeVar("T")
 
 
@@ -142,7 +141,7 @@ class PreprocessingValidationResult:
     def __init__(
         self,
         policy: str,
-        notepad: NotepadTree,
+        notepad: Notepad,
     ):
         self._policy = policy
         self._notepad = notepad
@@ -175,7 +174,7 @@ def is_ok(self) -> bool:
 
     def summarize(
         self,
-        file: io.TextIOBase = sys.stdout,
+        file: typing.TextIO = sys.stdout,
         indent: int = 2,
     ):
         """
@@ -189,13 +188,13 @@ def summarize(
         file.write(f"Validated under {self._policy} policy")
         file.write(os.linesep)
 
-        n_errors = sum(node.error_count() for node in self._notepad.iterate_nodes())
-        n_warnings = sum(node.warning_count() for node in self._notepad.iterate_nodes())
+        n_errors = sum(node.error_count() for node in self._notepad.iter_sections())
+        n_warnings = sum(node.warning_count() for node in self._notepad.iter_sections())
         if n_errors > 0 or n_warnings > 0:
             file.write("Showing errors and warnings")
             file.write(os.linesep)
 
-            for node in self._notepad.iterate_nodes():
+            for node in self._notepad.iter_sections():
                 if node.has_errors_or_warnings(include_subsections=True):
                     # We must report the node label even if there are no issues with the node.
                     l_pad = " " * (node.level * indent)
@@ -207,9 +206,9 @@ def summarize(
                         file.write(os.linesep)
                         for error in node.errors():
                             file.write(
-                                l_pad + " " + error.message + f". {error.solution}"
-                                if error.solution
-                                else ""
+                                l_pad + " ·" + error.message + (
+                                    f". {error.solution}" if error.solution else ""
+                                )
                             )
                             file.write(os.linesep)
                     if node.has_warnings():
@@ -217,9 +216,9 @@ def summarize(
                         file.write(os.linesep)
                         for warning in node.warnings():
                             file.write(
-                                l_pad + " ·" + warning.message + f". {warning.solution}"
-                                if warning.solution
-                                else ""
+                                l_pad + " ·" + warning.message + (
+                                    f". {warning.solution}" if warning.solution else ""
+                                )
                             )
                             file.write(os.linesep)
         else:
diff --git a/src/gpsea/preprocessing/_audit.py b/src/gpsea/preprocessing/_audit.py
deleted file mode 100644
index 3bdafe97..00000000
--- a/src/gpsea/preprocessing/_audit.py
+++ /dev/null
@@ -1,372 +0,0 @@
-import abc
-import enum
-import typing
-
-
-class Level(enum.Enum):
-    """
-    An enum to represent severity of the :class:`DataSanityIssue`.
-    """
-
-    WARN = enum.auto()
-    """
-    Warning is an issue when something not entirely right. However, unlike :class:`Level.ERROR`, the analysis should
-    complete albeit with sub-optimal results 😧.
-    """
-
-    ERROR = enum.auto()
-    """
-    Error is a serious issue in the input data and the downstream analysis may not complete or the analysis results
-    may be malarkey 😱.
-    """
-
-    def __str__(self):
-        return self.name
-
-
-class DataSanityIssue:
-    """
-    `DataSanityIssue` summarizes an issue found in the input data.
-
-    The issue has a `level`, a `message` with human-friendly description, and an optional `solution`
-    for removing the issue.
-    """
-
-    def __init__(self, level: Level,
-                 message: str,
-                 solution: typing.Optional[str] = None):
-        self._level = level
-        self._message = message
-        self._solution = solution
-
-    @property
-    def level(self) -> Level:
-        return self._level
-
-    @property
-    def message(self) -> str:
-        return self._message
-
-    @property
-    def solution(self) -> typing.Optional[str]:
-        return self._solution
-
-    def __str__(self):
-        return f'DataSanityIssue(level={self._level}, message={self._message}, solution={self._solution})'
-
-    def __repr__(self):
-        return str(self)
-
-
-IN = typing.TypeVar('IN')
-"""
-:class:`Auditor` input.
-"""
-
-OUT = typing.TypeVar('OUT')
-"""
-:class:`Auditor` output format.
-"""
-
-
-# TODO: remove
-class AuditReport(typing.Generic[OUT]):
-    """
-    `AuditReport` includes the issues found by :class:`Auditor` and the outcome of the sanitation.
-    """
-
-    def __init__(self, outcome: OUT,
-                 issues: typing.Iterable[DataSanityIssue]):
-        self._outcome = outcome
-        self._issues = tuple(issues)
-
-    @property
-    def outcome(self) -> OUT:
-        return self._outcome
-
-    @property
-    def issues(self) -> typing.Sequence[DataSanityIssue]:
-        """
-        Returns:
-            a sequence of :class:`DataSanityIssue`.
-        """
-        return self._issues
-
-    def warnings(self) -> typing.Iterator[DataSanityIssue]:
-        """
-        Returns:
-            an iterator over the warnings.
-        """
-        return filter(lambda i: i.level == Level.WARN, self.issues)
-
-    def errors(self) -> typing.Iterator[DataSanityIssue]:
-        """
-        Returns:
-            an iterator over the errors.
-        """
-        return filter(lambda i: i.level == Level.ERROR, self.issues)
-
-    def has_errors(self) -> bool:
-        """
-        Returns:
-            bool: `True` if one or more errors were found in the input.
-        """
-        for _ in self.errors():
-            return True
-        return False
-
-    def has_warnings_or_errors(self) -> bool:
-        """
-        Returns:
-            bool: `True` if one or more errors or warnings were found in the input.
-        """
-        for _ in self.warnings():
-            return True
-        for _ in self.errors():
-            return True
-        return False
-
-    def n_warnings(self) -> int:
-        """
-        Returns:
-            int: the number of warnings in the audit report.
-        """
-        return sum(1 for issue in self.issues if issue.level == Level.WARN)
-
-    def n_errors(self) -> int:
-        """
-        Returns:
-            int: the number of errors in the audit report.
-        """
-        return sum(1 for issue in self.issues if issue.level == Level.ERROR)
-
-    def __str__(self):
-        return f'AuditReport(issues={self._issues}, outcome={self._outcome})'
-
-    def __repr__(self):
-        return str(self)
-
-
-class Notepad(metaclass=abc.ABCMeta):
-    """
-    Record issues encountered during parsing/validation of a hierarchical data structure.
-
-    The issues can be organized in sections. `Notepad` keeps track of issues in one section
-    and the subsections can be created by calling :func:`add_subsection`. The function returns
-    an instance responsible for issues of a subsection.
-
-    A collection of the issues from the current section are available via :attr:`issues` property
-    and the convenience functions provide iterators over error and warnings.
-    """
-
-    def __init__(self, label: str):
-        self._label = label
-        self._issues: typing.MutableSequence[DataSanityIssue] = []
-
-
-    @abc.abstractmethod
-    def add_subsection(self, label: str) -> "Notepad":
-        """
-        Add a labeled subsection.
-
-        Returns:
-            Notepad: a notepad for recording issues within the subsection.
-        """
-        pass
-
-    @property
-    def label(self) -> str:
-        """
-        Get a `str` with the section label.
-        """
-        return self._label
-
-    @property
-    def issues(self) -> typing.Sequence[DataSanityIssue]:
-        """
-        Get an iterable with the issues of the current section.
-        """
-        return self._issues
-
-    def add_issue(self, level: Level, message: str, solution: typing.Optional[str] = None):
-        """
-        Add an issue with certain `level`, `message`, and an optional `solution`.
-        """
-        self._issues.append(DataSanityIssue(level, message, solution))
-
-    def add_error(self, message: str, solution: typing.Optional[str] = None):
-        """
-        A convenience function for adding an *error* with a `message` and an optional `solution`.
-        """
-        self.add_issue(Level.ERROR, message, solution)
-
-    def add_warning(self, message: str, solution: typing.Optional[str] = None):
-        """
-        A convenience function for adding a *warning* with a `message` and an optional `solution`.
-        """
-        self.add_issue(Level.WARN, message, solution)
-
-    def errors(self) -> typing.Iterator[DataSanityIssue]:
-        """
-        Iterate over the errors of the current section.
-        """
-        return filter(lambda dsi: dsi.level == Level.ERROR, self.issues)
-
-    def warnings(self) -> typing.Iterator[DataSanityIssue]:
-        """
-        Iterate over the warnings of the current section.
-        """
-        return filter(lambda dsi: dsi.level == Level.WARN, self.issues)
-
-    def error_count(self) -> int:
-        """
-        Returns:
-            int: count of errors found in this section.
-        """
-        return sum(1 for _ in self.errors())
-
-    def warning_count(self) -> int:
-        """
-        Returns:
-            int: count of warnings found in this section.
-        """
-        return sum(1 for _ in self.warnings())
-
-
-class NotepadTree(Notepad):
-    """
-    `NotepadTree` implements :class:`Notepad` using a tree where each tree node corresponds to a (sub)section. The node
-    can have `0..n` children.
-
-    Each node has a :attr:`label`, a collection of issues, and children with subsections. For convenience, the node
-    has :attr:`level` to correspond to the depth of the node within the tree (the level of the root node is `0`).
-
-    The nodes can be accessed via :attr:`children` property or through convenience methods for tree traversal, either
-    using the visitor pattern (:func:`visit`) or by iterating over the nodes via :func:`iterate_nodes`. In both cases,
-    the traversal is done in the depth-first fashion.
-    """
-
-    def __init__(self, label: str, level: int):
-        super().__init__(label)
-        self._level = level
-        self._children = []
-
-    @property
-    def children(self):
-        return self._children
-
-    @property
-    def level(self) -> int:
-        return self._level
-
-    def add_subsection(self, identifier: str):
-        sub = NotepadTree(identifier, self._level + 1)
-        self._children.append(sub)
-        return sub
-
-    def visit(self, visitor):
-        """
-        Perform a depth-first search on the tree and call `visitor` with all nodes.
-        Args:
-            visitor: a callable that takes the current node as a single argument.
-        """
-        stack = [self]
-
-        while stack:
-            node = stack.pop()
-            # Reversed to visit in the add order.
-            stack.extend(reversed(node.children))
-            visitor(node)
-
-    def iterate_nodes(self):
-        """
-        Iterate over nodes in the depth-first fashion.
-
-        Returns: a depth-first node iterator.
-        """
-        stack = [self]
-        while stack:
-            node = stack.pop()
-            stack.extend(reversed(node.children))
-            yield node
-
-    def has_warnings(self, include_subsections: bool = False) -> bool:
-        """
-        Returns:
-            bool: `True` if one or more warnings were found in the current section or its subsections.
-        """
-        if include_subsections:
-            for node in self.iterate_nodes():
-                for _ in node.warnings():
-                    return True
-        else:
-            for _ in self.warnings():
-                return True
-
-        return False
-
-    def has_errors(self, include_subsections: bool = False) -> bool:
-        """
-        Returns:
-            bool: `True` if one or more errors were found in the current section or its subsections.
-        """
-        if include_subsections:
-            for node in self.iterate_nodes():
-                for _ in node.errors():
-                    return True
-        else:
-            for _ in self.errors():
-                return True
-
-        return False
-
-    def has_errors_or_warnings(self, include_subsections: bool = False) -> bool:
-        """
-        Returns:
-            bool: `True` if one or more errors or warnings were found in the current section or its subsections.
-        """
-        if include_subsections:
-            for node in self.iterate_nodes():
-                for _ in node.warnings():
-                    return True
-                for _ in node.errors():
-                    return True
-        else:
-            for _ in self.warnings():
-                return True
-            for _ in self.errors():
-                return True
-
-        return False
-
-    def __str__(self):
-        return f'NotepadTree(label={self._label}, level={self._level}, children={[ch.identifier for ch in self._children]})'
-
-
-class Auditor(typing.Generic[IN, OUT], metaclass=abc.ABCMeta):
-    """
-    `Auditor` checks the inputs for sanity issues and relates the issues with sanitized inputs
-    as :class:`SanitationResults`.
-
-    The auditor may sanitize the input as a matter of discretion and returns the input as `OUT`.
-    """
-
-    @staticmethod
-    def prepare_notepad(label: str) -> NotepadTree:
-        """
-        Prepare a :class:`Notepad` for recording issues and errors.
-
-        Args:
-            label: a `str` with the top-level section label.
-
-        Returns:
-            NotepadTree: an instance of :class:`NotepadTree`.
-        """
-        return NotepadTree(label, level=0)
-
-    @abc.abstractmethod
-    def process(self, data: IN, notepad: Notepad) -> OUT:
-        """
-        Audit and sanitize the `data`, record the issues to the `notepad` and return the sanitized data.
-        """
-        pass
diff --git a/src/gpsea/preprocessing/_config.py b/src/gpsea/preprocessing/_config.py
index 8ff816b9..b69ac7e9 100644
--- a/src/gpsea/preprocessing/_config.py
+++ b/src/gpsea/preprocessing/_config.py
@@ -11,6 +11,8 @@
     PhenotypicAbnormalityValidator,
 )
 
+from stairval.notepad import create_notepad
+
 # pyright: reportGeneralTypeIssues=false
 from google.protobuf.json_format import Parse
 from phenopackets import Phenopacket
@@ -375,7 +377,7 @@ def load_phenopackets(
     cohort_iter = tqdm(
         phenopackets, desc="Individuals Processed", file=sys.stdout, unit="individuals"
     )
-    notepad = cohort_creator.prepare_notepad("Phenopackets")
+    notepad = create_notepad(label="Phenopackets")
     cohort = cohort_creator.process(cohort_iter, notepad)
 
     validation_result = PreprocessingValidationResult(
diff --git a/src/gpsea/preprocessing/_patient.py b/src/gpsea/preprocessing/_patient.py
index 7a9ed95c..7f51ed50 100644
--- a/src/gpsea/preprocessing/_patient.py
+++ b/src/gpsea/preprocessing/_patient.py
@@ -2,12 +2,10 @@
 
 import typing
 
-import hpotk
+from stairval.notepad import Notepad
 
 from gpsea.model import Patient, Cohort
 
-from ._audit import Auditor, Notepad
-
 T = typing.TypeVar('T')
 """
 The input for `PatientCreator`.
@@ -16,27 +14,40 @@
 """
 
 
-class PatientCreator(typing.Generic[T], Auditor[T, Patient], metaclass=abc.ABCMeta):
+class PatientCreator(typing.Generic[T], metaclass=abc.ABCMeta):
     """
     `PatientCreator` can create a `Patient` from some input `T`.
-
-    `PatientCreator` is an `Auditor`, hence the input is sanitized and any errors are reported to the caller.
     """
-    pass
+    
+    @abc.abstractmethod
+    def process(
+        self,
+        item: T,
+        notepad: Notepad,
+    ) -> typing.Optional[Patient]:
+        pass
 
 
-class CohortCreator(typing.Generic[T], Auditor[typing.Iterable[T], Cohort]):
+class CohortCreator(typing.Generic[T]):
     """
     `CohortCreator` creates a cohort from an iterable of some `T` where `T` represents a cohort member.
     """
 
-    def __init__(self, patient_creator: PatientCreator[T]):
+    def __init__(
+        self,
+        patient_creator: PatientCreator[T],
+    ):
         # Check that we're getting a `PatientCreator`.
         # Unfortunately, we cannot check that `T`s of `PatientCreator` and `CohortCreator` actually match
         # due to Python's loosey-goosey nature.
-        self._pc = hpotk.util.validate_instance(patient_creator, PatientCreator, 'patient_creator')
-
-    def process(self, inputs: typing.Iterable[T], notepad: Notepad) -> Cohort:
+        assert isinstance(patient_creator, PatientCreator)
+        self._pc = patient_creator
+
+    def process(
+        self,
+        inputs: typing.Iterable[T],
+        notepad: Notepad,
+    ) -> Cohort:
         patients = []
         patient_labels = set()
         duplicate_pat_labels = set()
@@ -44,21 +55,26 @@ def process(self, inputs: typing.Iterable[T], notepad: Notepad) -> Cohort:
         for i, pp in enumerate(inputs):
             sub = notepad.add_subsection(f'patient #{i}')
             patient = self._pc.process(pp, sub)
-            if patient.labels in patient_labels:
-                duplicate_pat_labels.add(patient.labels)
-            patient_labels.add(patient.labels)
-            patients.append(patient)
+            if patient is not None:
+                if patient.labels in patient_labels:
+                    duplicate_pat_labels.add(patient.labels)
+                patient_labels.add(patient.labels)
+                patients.append(patient)
 
         # What happens if a sample has
         if len(duplicate_pat_labels) > 0:
             label_summaries = [d.label_summary() for d in duplicate_pat_labels]
             label_summaries.sort()
-            notepad.add_error(f"Patient ID/s {', '.join(label_summaries)} have a duplicate",
-                              "Please verify every patient has an unique ID.")
+            notepad.add_error(
+                f"Patient ID/s {', '.join(label_summaries)} have a duplicate",
+                "Please verify every patient has an unique ID.",
+            )
 
         # We should have >1 patients in the cohort, right?
         if len(patients) <= 1:
-            notepad.add_warning(f'Cohort must include {len(patients)}>1 members',
-                                'Fix issues in patients to enable the analysis')
+            notepad.add_error(
+                f'Cohort must include {len(patients)}>1 members',
+                'Fix issues in patients to enable the analysis',
+            )
 
         return Cohort.from_patients(patients)
diff --git a/src/gpsea/preprocessing/_phenopacket.py b/src/gpsea/preprocessing/_phenopacket.py
index 68d38c51..3c7486c6 100644
--- a/src/gpsea/preprocessing/_phenopacket.py
+++ b/src/gpsea/preprocessing/_phenopacket.py
@@ -5,6 +5,9 @@
 from hpotk.util import validate_instance
 from hpotk.validate import ValidationRunner, ValidationLevel
 
+from stairval import Level
+from stairval.notepad import Notepad
+
 import phenopackets.schema.v2.core.individual_pb2 as ppi
 from phenopackets.schema.v2.phenopackets_pb2 import Phenopacket
 from phenopackets.schema.v2.core.base_pb2 import TimeElement as PPTimeElement
@@ -35,7 +38,6 @@
     FunctionalAnnotator,
     ImpreciseSvFunctionalAnnotator,
 )
-from ._audit import Notepad, Level
 from ._patient import PatientCreator
 
 
@@ -285,7 +287,11 @@ def __init__(
             "1000044",  # chromosomal_translocation
         }
 
-    def process(self, pp: Phenopacket, notepad: Notepad) -> Patient:
+    def process(
+        self,
+        pp: Phenopacket,
+        notepad: Notepad,
+    ) -> typing.Optional[Patient]:
         """Creates a Patient from the data in a given Phenopacket
 
         Args:
diff --git a/src/gpsea/view/_cohort.py b/src/gpsea/view/_cohort.py
index da154cfd..2cd6d404 100644
--- a/src/gpsea/view/_cohort.py
+++ b/src/gpsea/view/_cohort.py
@@ -1,14 +1,14 @@
 import typing
 
+from collections import namedtuple, defaultdict
+
 from hpotk import MinimalOntology
 from jinja2 import Environment, PackageLoader
-from collections import namedtuple
 
-from gpsea.model import Cohort
+from gpsea.model import Cohort, Sex
 from ._report import GpseaReport, HtmlGpseaReport
 from ._formatter import VariantFormatter
 
-
 ToDisplay = namedtuple('ToDisplay', ['hgvs_cdna', 'hgvsp', 'variant_effects'])
 
 
@@ -75,6 +75,22 @@ def _prepare_context(
                     "Count": individual_count,
                 }
             )
+        # Show the same max number of measuresments as phenotypes
+        measurement_counts = list()
+        for msmt in cohort.list_measurements(top=self._top_phenotype_count):
+            msmt_id = msmt[0]
+            individual_count = msmt[1]
+            msmt_label = "n/a"
+            #if hpo_id in self._hpo:
+            #    hpo_label = self._hpo.get_term_name(hpo_id)
+            msmt_label = "need a way to retrieve measuremnt label"
+            measurement_counts.append(
+                {
+                    "Assay": msmt_label,
+                    "ID": msmt_id,
+                    "Count": individual_count,
+                })
+        n_measurements = len(measurement_counts)
 
         variant_counts = list()
         variant_to_display_d = CohortViewer._get_variant_description(cohort, transcript_id)
@@ -105,7 +121,7 @@ def _prepare_context(
         for disease_id, disease_count in cohort.list_all_diseases():
             disease_name = "Unknown"
             for disease in cohort.all_diseases():
-                if disease.identifier == disease_id:
+                if disease.identifier.value == disease_id:
                     disease_name = disease.name
             disease_counts.append(
                 {
@@ -138,15 +154,77 @@ def _prepare_context(
         else:
             has_transcript = False
             transcript_id = "MANE transcript ID"
-            
+
+        n_male = 0
+        n_female = 0
+        n_unknown_sex = 0
+        n_deceased = 0
+        n_alive = 0
+        n_has_age_at_last_encounter = 0
+        n_has_disease_onset = 0
+        hpo_id_to_has_cnset_count_d = defaultdict(int)
+        for pat in cohort.all_patients:
+            if pat.sex == Sex.MALE:
+                n_male += 1
+            elif pat.sex == Sex.FEMALE:
+                n_female += 1
+            else:
+                n_unknown_sex += 1
+            patient_not_deceased = True
+            if pat.vital_status is not None:
+                if pat.vital_status.is_deceased:
+                    n_deceased += 1
+                    patient_not_deceased = False
+            if patient_not_deceased:
+                n_alive += 1
+            if pat.age is not None:
+                n_has_age_at_last_encounter += 1
+            diseases = pat.diseases
+            for d in diseases:
+                if d.onset is not None:
+                    n_has_disease_onset += 1
+                    break  # for now, do this for any diseases. All of our current phenopackets habve but one disease
+            terms_and_ancestors_with_onset_information = set()
+            for pf in pat.present_phenotypes():
+                if pf.onset is not None:
+                    ancs = self._hpo.graph.get_ancestors(pf.identifier, include_source=True)
+                    terms_and_ancestors_with_onset_information.update(ancs)
+            for hpo_id in terms_and_ancestors_with_onset_information:
+                hpo_id_to_has_cnset_count_d[hpo_id] += 1
+        # When we get here, we want to present the counts of HPO terms that have onset information
+        n_individuals = len(cohort.all_patients)
+        onset_threshold = int(0.2 * n_individuals)  # only show terms with a decent amount of information
+        has_onset_information = list()
+        for hpo_id, count in hpo_id_to_has_cnset_count_d.items():
+            if count < onset_threshold:
+                continue
+            label = self._hpo.get_term_name(hpo_id)
+            if label is not None:
+                display_label = f"{label} ({hpo_id})"
+            else:
+                display_label = hpo_id
+            has_onset_information.append({"HPO": display_label, "count": count})
+        n_has_onset_info = len(has_onset_information)
+
         # The following dictionary is used by the Jinja2 HTML template
         return {
-            "n_individuals": len(cohort.all_patients),
+            "n_individuals": n_individuals,
+            "n_male": n_male,
+            "n_female": n_female,
+            "n_unknown_sex": n_unknown_sex,
+            "n_deceased": n_deceased,
+            "n_alive": n_alive,
+            "n_has_disease_onset": n_has_disease_onset,
+            "n_has_age_at_last_encounter": n_has_age_at_last_encounter,
+            "has_onset_information": has_onset_information,
+            "n_has_onset_info": n_has_onset_info,
             "n_excluded": cohort.get_excluded_count(),
             "total_hpo_count": len(cohort.all_phenotypes()),
             "top_hpo_count": self._top_phenotype_count,
             "hpo_counts": hpo_counts,
-            "unique_variant_count": len(cohort.all_variants()),
+            "total_measurement_count": n_measurements,
+            "measurement_counts": measurement_counts,
+            "unique_variant_count": len(cohort.all_variant_infos()),
             "top_var_count": self._top_variant_count,
             "var_counts": variant_counts,
             "n_diseases": n_diseases,
@@ -184,14 +262,14 @@ def _get_variant_description(
                 tx_annotation = None
             else:
                 tx_annotation = var.get_tx_anno_by_tx_id(transcript_id)
-            
+
             if tx_annotation is None:
                 hgvsp = None
                 var_effects = None
             else:
                 hgvsp = tx_annotation.hgvsp
                 var_effects = [var_eff.name for var_eff in tx_annotation.variant_effects]
-    
+
             if only_hgvs:
                 # do not show the transcript id
                 fields_dna = display.split(":")
diff --git a/src/gpsea/view/_disease.py b/src/gpsea/view/_disease.py
index 650220d4..350a61f0 100644
--- a/src/gpsea/view/_disease.py
+++ b/src/gpsea/view/_disease.py
@@ -26,12 +26,12 @@ def process(
         self,
         cohort: Cohort,
     ) -> GpseaReport:
-        context = DiseaseViewer._prepare_context(cohort)
+        context = self._prepare_context(cohort)
         html = self._cohort_template.render(context)
         return HtmlGpseaReport(html=html)
-    
-    @staticmethod
+
     def _prepare_context(
+        self,
         cohort: Cohort,
     ) -> typing.Mapping[str, typing.Any]:
         diseases = cohort.list_all_diseases()
@@ -39,21 +39,32 @@ def _prepare_context(
         disease_counts = list()
         disease_variants_counts = defaultdict(Counter)
         disease_effects_counts = {}
-        for d in diseases:
-            disease_id = d[0]
-            disease_count = d[1]
+        for curie, count in diseases:
             disease_name = "Unknown"
-            disease_variants_counts[disease_id.value] = Counter()
-            disease_effects_counts[disease_id.value] = Counter()
+            disease_variants_counts[curie] = Counter()
+            disease_effects_counts[curie] = Counter()
+            
             for dis in cohort.all_diseases():
-                if dis.identifier == d[0]:
+                if dis.identifier.value == curie:
                     disease_name = dis.name
+                    break
+            
+            disease_counts.append(
+                {
+                    "disease_id": curie,
+                    "disease_name": disease_name,
+                    "count": count,
+                }
+            )
+
             for pat in cohort.all_patients:
-                if disease_id in [disease.identifier for disease in pat.diseases]:
-                    disease_variants_counts[disease_id.value].update(variant.variant_coordinates.variant_key for variant in pat.variants)
-                    for var_effect in [txa.variant_effects for variant in pat.variants for txa in variant.tx_annotations if txa.transcript_id == self._tx_id]:
-                        disease_effects_counts[disease_id.value].update(var_eff.name for var_eff in var_effect)
-            disease_counts.append({"disease_id": disease_id, "disease_name": disease_name, "count": disease_count})
+                for disease in pat.diseases:
+                    variant_key_count = disease_variants_counts[disease.identifier.value]
+                    variant_key_count.update(variant.variant_info.variant_key for variant in pat.variants)
+                    for variant in pat.variants:
+                        for txa in variant.tx_annotations:
+                            if self._tx_id == txa.transcript_id:
+                                disease_effects_counts[curie].update(eff.name for eff in txa.variant_effects)
             
         return {
             'n_diseases': n_diseases,
diff --git a/src/gpsea/view/_phenotype_analysis.py b/src/gpsea/view/_phenotype_analysis.py
index 256c3fb3..d2645e34 100644
--- a/src/gpsea/view/_phenotype_analysis.py
+++ b/src/gpsea/view/_phenotype_analysis.py
@@ -23,7 +23,7 @@ def summarize_hpo_analysis(
     # Column index: multiindex of counts and percentages for all genotype predicate groups
     gt_idx = pd.MultiIndex.from_product(
         iterables=(result.gt_predicate.get_categories(), ("Count", "Percent")),
-        names=(result.gt_predicate.get_question_base(), None),
+        names=(result.gt_predicate.variable_name, None),
     )
 
     # We'll fill this frame with data
diff --git a/src/gpsea/view/_stats.py b/src/gpsea/view/_stats.py
index e2fdbf39..f61e7e40 100644
--- a/src/gpsea/view/_stats.py
+++ b/src/gpsea/view/_stats.py
@@ -66,7 +66,7 @@ def _prepare_context(
 
         # The following dictionary is used by the Jinja2 HTML template
         return {
-            "mtc_name": report.mtc_name,
+            "mtc_name": report.mtc_correction,
             "hpo_mtc_filter_name": report.mtc_filter_name,
             "skipped_hpo_count": n_skipped,
             "tested_hpo_count": n_tested,
diff --git a/src/gpsea/view/templates/cohort.html b/src/gpsea/view/templates/cohort.html
index c57920e8..fdd45d0a 100644
--- a/src/gpsea/view/templates/cohort.html
+++ b/src/gpsea/view/templates/cohort.html
@@ -64,7 +64,16 @@
 
 <body>
   <h1>GPSEA cohort analysis</h1>
-  <p>Successfully loaded {{ n_individuals }} individuals.</p>
+  <p>Successfully loaded {{ n_individuals }} individuals. {{ n_male }} were recorded as male, {{ n_female }} as female,
+      and {{ n_unknown_sex }} as unknown sex. {{ n_alive }} were reported to be alive at the time of last
+      encounter, and {{ n_deceased }} were deceased. {{ n_has_disease_onset }} had disease onset information
+      and {{n_has_age_at_last_encounter  }} had information about the age at last encounter. In the cohort,
+      there were a total of {{ total_hpo_count }} HPO terms used for annotation.
+    {% if total_measurement_count > 0 %}
+      Information was provided for {{ total_measurement_count }}
+      measurement assays.
+    {% endif %}
+  </p>
   {% if n_excluded > 0 %}
   <p>Not able to load {{ n_excluded }} individuals.</p>
   {% else %}
@@ -92,6 +101,29 @@ <h3>Top {{top_hpo_count}} HPO Terms</h3>
                 </tbody>
     </table>
 
+  {% if n_has_onset_info > 0 %}
+      <p> {{ n_has_onset_info }} had onset information for at least 20% of individuals.</p>
+       <caption style="color: black;">
+        <h3>Top {{top_var_count}} HPO Terms</h3>
+        A total of {{ total_hpo_count }} HPO terms were used to annotated the cohort.
+    </caption>
+            <tbody>
+                <tr class="strng">
+                    <th>HPO</th>
+                    <th>Count</th>
+                </tr>
+                {% for onset_info in has_onset_information %}
+                <tr>
+                    <td>{{ onset_info.display_label }}</td>
+                    <td>{{ onset_info.count }}</td>
+                </tr>
+                {% endfor %}
+            </tbody>
+        </table>
+    <table>
+
+  {% endif %}
+
     <table>
     <caption style="color: black;">
         <h3>Top {{top_var_count}} Variants</h3>
@@ -99,7 +131,7 @@ <h3>Top {{top_var_count}} Variants</h3>
     </caption>
             <tbody>
                 <tr class="strng">
-                    <th>Count</th>
+                    <th>Seen in <em>n</em> individuals</th>
                     <th class="lft">Variant key</th>
                     <th>Variant Name</th>
                     <th>Protein Variant</th>


Test patch:
diff --git a/src/gpsea/analysis/predicate/genotype/_test__gt_predicates.py b/src/gpsea/analysis/predicate/genotype/_test__gt_predicates.py
index 4c7f3d25..0eb50d0c 100644
--- a/src/gpsea/analysis/predicate/genotype/_test__gt_predicates.py
+++ b/src/gpsea/analysis/predicate/genotype/_test__gt_predicates.py
@@ -1,11 +1,7 @@
 import typing
 import pytest
 
-from ._gt_predicates import (
-    build_count_to_cat,
-    ModeOfInheritancePredicate,
-    ModeOfInheritanceInfo,
-)
+from ._gt_predicates import _build_count_to_cat, _build_ac_to_cat, _qc_partitions
 
 
 @pytest.mark.parametrize(
@@ -48,8 +44,9 @@ def test_build_count_to_cat(
     partitions: typing.Sequence[typing.Sequence[int]],
     ac2cat_name: typing.Mapping[typing.Tuple[int, int], str],
 ):
-    ac2cat = build_count_to_cat(
-        a_label="A", b_label="B",
+    ac2cat = _build_count_to_cat(
+        a_label="A",
+        b_label="B",
         partitions=partitions,
     )
 
@@ -59,45 +56,90 @@ def test_build_count_to_cat(
 
 
 @pytest.mark.parametrize(
-    "partitions, moi_data, ac2cat_name",
+    "partitions, ac2cat_name",
     [
         (
-            [(0,), (1,)],
-            ModeOfInheritanceInfo.autosomal_dominant(),
+            ((0,), (1,), (2,)),
+            {
+                0: "0",
+                1: "1",
+                2: "2",
+            },
+        ),
+        (
+            ((0, 1), (2,)),
             {
-                0: "No allele",
-                1: "Monoallelic",
+                0: "0 OR 1",
+                1: "0 OR 1",
+                2: "2",
             },
         ),
         (
-            [(0,), (1,), (2,)],
-            ModeOfInheritanceInfo.autosomal_recessive(),
+            ((0,), (1, 2)),
             {
-                0: "No allele",
-                1: "Monoallelic",
-                2: "Biallelic",
+                0: "0",
+                1: "1 OR 2",
+                2: "1 OR 2",
             },
         ),
         (
-            [(1,), (2,)],
-            ModeOfInheritanceInfo.autosomal_recessive(),
+            ((1,), (2,)),
             {
-                1: "Monoallelic",
-                2: "Biallelic",
+                1: "1",
+                2: "2",
             },
         ),
     ],
 )
-def test_prepare_count_to_cat(
+def test__build_ac_to_cat(
     partitions: typing.Sequence[typing.Sequence[int]],
-    moi_data: ModeOfInheritanceInfo,
     ac2cat_name: typing.Mapping[int, str],
 ):
-    ac2cat = ModeOfInheritancePredicate.prepare_count2cat(
+    ac2cat = _build_ac_to_cat(
         partitions=partitions,
-        mode_of_inheritance_data=moi_data,
     )
 
     for ac, expected_cat_name in ac2cat_name.items():
         categorization = ac2cat[ac]
         assert categorization.category.name == expected_cat_name
+
+
+@pytest.mark.parametrize(
+    "partitions, expected",
+    [
+        (
+            ((0, 1), (1,)),
+            "element 1 was present 2!=1 times",
+        ),
+        (((0,), (0,), (2,)),
+            "partition (0,) was present 2!=1 times"),
+        (
+            ((0, 1), (1, 2,)),
+            "element 1 was present 2!=1 times",
+        ),
+        (
+            ((0, 1), (1, 0)),
+            "element 0 was present 2!=1 times, element 1 was present 2!=1 times",
+        ),
+        (
+            ((0, 1, 1), (1, 1, 2,)),
+            "element 1 was present 4!=1 times",
+        ),
+        (
+            ((0.1,), (1,), (2,)),
+            "Each partition index must be a non-negative int",
+        ),
+        (
+            ((0, 1, 2),),
+            "At least 2 partitions must be provided",
+        ),
+    ],
+)
+def test__qc_partitions__explodes_when_expected(
+    partitions: typing.Sequence[typing.Sequence[int]],
+    expected: str,
+):
+    with pytest.raises(ValueError) as e:
+        _qc_partitions(partitions=partitions)
+
+    assert e.value.args == (expected,)
diff --git a/tests/analysis/predicate/genotype/conftest.py b/tests/analysis/predicate/genotype/conftest.py
index 69741412..27da841e 100644
--- a/tests/analysis/predicate/genotype/conftest.py
+++ b/tests/analysis/predicate/genotype/conftest.py
@@ -2,47 +2,27 @@
 
 import hpotk
 
-from gpsea.model import *
-from gpsea.model.genome import *
-from gpsea.preprocessing import ProteinMetadataService
-
-from gpsea.analysis.predicate.genotype import ProteinPredicates
-
-
-@pytest.fixture(scope="package")
-def protein_metadata_service() -> ProteinMetadataService:
-    response = ProteinMetadata(
-        protein_id="pt:xyz",
-        label="xyz_label",
-        protein_features=(
-            ProteinFeature.create(
-                FeatureInfo(name="MOCK_REPEAT", region=Region(55, 80)),
-                FeatureType.REPEAT,
-            ),
-            ProteinFeature.create(
-                FeatureInfo(name="MOCK_DOMAIN", region=Region(30, 50)),
-                FeatureType.DOMAIN,
-            ),
-        ),
-        protein_length=100,
-    )
-    return MockProteinMetadataService(response)
-
-
-@pytest.fixture(scope="package")
-def protein_predicates(
-    protein_metadata_service: ProteinMetadataService,
-) -> ProteinPredicates:
-    return ProteinPredicates(protein_metadata_service)
-
-
-class MockProteinMetadataService(ProteinMetadataService):
-
-    def __init__(self, response: ProteinMetadata):
-        self._response = response
-
-    def annotate(self, protein_id: str) -> ProteinMetadata:
-        return self._response
+from gpsea.model import (
+    Genotype,
+    Genotypes,
+    ImpreciseSvInfo,
+    Patient,
+    SampleLabels,
+    Sex,
+    TranscriptAnnotation,
+    Variant,
+    VariantClass,
+    VariantCoordinates,
+    VariantEffect,
+    VariantInfo,
+)
+from gpsea.model.genome import (
+    GenomeBuild,
+    GenomicRegion,
+    Region,
+    Strand,
+
+)
 
 
 @pytest.fixture(scope="package")
@@ -304,9 +284,7 @@ def genesis_synonymous_mutation(
                 tx_id="tx:xyz",
                 hgvs_cdna=None,
                 is_preferred=True,
-                variant_effects=(
-                    VariantEffect.SYNONYMOUS_VARIANT,
-                ),
+                variant_effects=(VariantEffect.SYNONYMOUS_VARIANT,),
                 affected_exons=(5,),
                 protein_id="pt:xyz",
                 hgvsp=None,
@@ -492,9 +470,7 @@ def white_synonymous_mutation(
                 tx_id="tx:xyz",
                 hgvs_cdna=None,
                 is_preferred=True,
-                variant_effects=(
-                    VariantEffect.SYNONYMOUS_VARIANT,
-                ),
+                variant_effects=(VariantEffect.SYNONYMOUS_VARIANT,),
                 affected_exons=(5,),
                 protein_id="pt:xyz",
                 hgvsp=None,
diff --git a/tests/analysis/predicate/genotype/test_allele_counter.py b/tests/analysis/predicate/genotype/test_allele_counter.py
index 6b20abcb..38bde819 100644
--- a/tests/analysis/predicate/genotype/test_allele_counter.py
+++ b/tests/analysis/predicate/genotype/test_allele_counter.py
@@ -1,8 +1,18 @@
 import pytest
 
-from gpsea.analysis.predicate.genotype import AlleleCounter, VariantPredicate
-from gpsea.model import *
-from gpsea.model.genome import *
+from gpsea.model import (
+    Genotype,
+    Genotypes,
+    Patient,
+    SampleLabels,
+    TranscriptAnnotation,
+    Variant,
+    VariantCoordinates,
+    VariantEffect,
+    VariantInfo,
+)
+from gpsea.model.genome import Contig, GenomeBuild, GenomicRegion, Region, Strand
+from gpsea.analysis.predicate.genotype import AlleleCounter, VariantPredicates
 
 
 @pytest.fixture(scope="module")
@@ -199,7 +209,7 @@ def test_count_keys(
         variant_key: str,
         expected: int,
     ):
-        predicate = MockVariantKeyPredicate(variant_key)
+        predicate = VariantPredicates.variant_key(key=variant_key)
         counter = AlleleCounter(predicate)
 
         assert counter.count(patient) == expected
@@ -216,38 +226,7 @@ def test_count_keys(
     def test_count_effects(
         self, patient: Patient, variant_effect: VariantEffect, tx_id: str, expected: int
     ):
-        predicate = MockVariantEffectPredicate(variant_effect, tx_id)
+        predicate = VariantPredicates.variant_effect(effect=variant_effect, tx_id=tx_id)
         counter = AlleleCounter(predicate)
 
         assert counter.count(patient) == expected
-
-
-class MockVariantKeyPredicate(VariantPredicate):
-
-    def __init__(self, variant_key: str):
-        self._variant_key = variant_key
-
-    def get_question(self) -> str:
-        return f"variant is {self._variant_key}"
-
-    def test(self, variant: Variant) -> bool:
-        return variant.variant_info.variant_key == self._variant_key
-
-
-class MockVariantEffectPredicate(VariantPredicate):
-
-    def __init__(self, variant_effect: VariantEffect, tx_id: str) -> None:
-        self._variant_effect = variant_effect
-        self._tx_id = tx_id
-
-    def get_question(self) -> str:
-        return f"variant has {self._variant_effect} on {self._tx_id}"
-
-    def test(self, variant: Variant) -> bool:
-        tx_anno = variant.get_tx_anno_by_tx_id(self._tx_id)
-        if tx_anno is None:
-            return False
-        for effect in tx_anno.variant_effects:
-            if effect == self._variant_effect:
-                return True
-        return False
diff --git a/tests/analysis/predicate/genotype/test_gt_predicates.py b/tests/analysis/predicate/genotype/test_gt_predicates.py
index 704d956f..bf9957a6 100644
--- a/tests/analysis/predicate/genotype/test_gt_predicates.py
+++ b/tests/analysis/predicate/genotype/test_gt_predicates.py
@@ -1,133 +1,63 @@
-import typing
 import pytest
 
 from gpsea.model import Patient, Sex, SampleLabels, VariantEffect
 from gpsea.analysis.predicate.genotype import (
-    GenotypePolyPredicate,
-    groups_predicate,
     sex_predicate,
     monoallelic_predicate,
     biallelic_predicate,
-    autosomal_dominant,
-    autosomal_recessive,
+    allele_count,
     VariantPredicates,
-    VariantPredicate,
 )
 
 
 TX_ID = "tx:xyz"
 
 
-class TestGroupsPredicate:
-
-    @pytest.fixture(scope="class")
-    def predicate(self) -> GenotypePolyPredicate:
-        return groups_predicate(
-            predicates=(
-                VariantPredicates.variant_effect(VariantEffect.MISSENSE_VARIANT, TX_ID),
-                VariantPredicates.variant_effect(
-                    VariantEffect.FRAMESHIFT_VARIANT, TX_ID
-                ),
-            ),
-            group_names=(
-                "Point",
-                "LoF",
-            ),
-        )
-
-    def test_get_question(
-        self,
-        predicate: GenotypePolyPredicate,
-    ):
-        question = predicate.get_question_base()
-        assert question == "Genotype group"
-
-    def test_get_categorizations(
-        self,
-        predicate: GenotypePolyPredicate,
-    ):
-        categorizations = predicate.get_categorizations()
-
-        names = [c.category.name for c in categorizations]
-        assert names == ["Point", "LoF"]
-
-        descriptions = [c.category.description for c in categorizations]
-        assert descriptions == [
-            "MISSENSE_VARIANT on tx:xyz",
-            "FRAMESHIFT_VARIANT on tx:xyz",
-        ]
-
-    def test_test__missense(
-        self,
-        patient_w_missense: Patient,
-        predicate: GenotypePolyPredicate,
-    ):
-        cat = predicate.test(patient_w_missense)
-
-        assert cat is not None
-        assert cat.category.cat_id == 0
-        assert cat.category.name == "Point"
-        assert cat.category.description == "MISSENSE_VARIANT on tx:xyz"
-
-    def test_test__frameshift(
-        self,
-        patient_w_frameshift: Patient,
-        predicate: GenotypePolyPredicate,
-    ):
-        cat = predicate.test(patient_w_frameshift)
-
-        assert cat is not None
-        assert cat.category.cat_id == 1
-        assert cat.category.name == "LoF"
-        assert cat.category.description == "FRAMESHIFT_VARIANT on tx:xyz"
-
-
-class TestModeOfInheritancePredicate:
-
-    @pytest.fixture(scope="class")
-    def variant_predicate(self) -> VariantPredicate:
-        return VariantPredicates.variant_effect(VariantEffect.MISSENSE_VARIANT, TX_ID)
+class TestAlleleCount:
 
     @pytest.mark.parametrize(
         "patient_name,name",
         [
-            ("adam", "No allele"),
-            ("eve", "Monoallelic"),
-            ("cain", "Monoallelic"),
+            ("adam", "1"),
+            ("eve", "1"),
+            ("cain", "1"),
         ],
     )
-    def test_autosomal_dominant(
+    def test_ad_family__all_variants(
         self,
         patient_name: str,
         name: str,
-        variant_predicate: VariantPredicate,
         request: pytest.FixtureRequest,
     ):
         patient = request.getfixturevalue(patient_name)
-        predicate = autosomal_dominant(variant_predicate)
+        predicate = allele_count(counts=((0,), (1,)))
 
         categorization = predicate.test(patient)
 
         assert categorization is not None
 
         assert categorization.category.name == name
-
+    
     @pytest.mark.parametrize(
         "patient_name,name",
         [
-            ("adam", "Monoallelic"),  # 0/0 & 0/1
-            ("eve", "Monoallelic"),  # 0/1 & 0/0
-            ("cain", "Monoallelic"),  # 0/1 & 0/0
+            ("adam", "0"),
+            ("eve", "1"),
+            ("cain", "1"),
         ],
     )
-    def test_autosomal_dominant__with_default_predicate(
+    def test_ad_family__missense_subset(
         self,
         patient_name: str,
         name: str,
         request: pytest.FixtureRequest,
     ):
+        is_missense = VariantPredicates.variant_effect(VariantEffect.MISSENSE_VARIANT, tx_id=TX_ID)
         patient = request.getfixturevalue(patient_name)
-        predicate = autosomal_dominant()
+        predicate = allele_count(
+            counts=((0,), (1,)),
+            target=is_missense,
+        )
 
         categorization = predicate.test(patient)
 
@@ -138,46 +68,48 @@ def test_autosomal_dominant__with_default_predicate(
     @pytest.mark.parametrize(
         "patient_name,name",
         [
-            ("walt", "Monoallelic"),
-            ("skyler", "Monoallelic"),
-            ("flynn", "Biallelic"),
-            ("holly", "No allele"),
+            ("walt", "2"),
+            ("skyler", "2"),
+            ("flynn", "2"),
+            ("holly", "2"),
         ],
     )
-    def test_autosomal_recessive(
+    def test_ar_family__all_variants(
         self,
         patient_name: str,
         name: str,
-        variant_predicate: VariantPredicate,
         request: pytest.FixtureRequest,
     ):
         patient = request.getfixturevalue(patient_name)
-        predicate = autosomal_recessive(variant_predicate)
+        predicate = allele_count(counts=((0, 1), (2,)))
 
         categorization = predicate.test(patient)
 
         assert categorization is not None
 
         assert categorization.category.name == name
-
+    
     @pytest.mark.parametrize(
         "patient_name,name",
         [
-            # The White family has two variants:
-            ("walt", "Biallelic"),  # 0/1 & 0/1
-            ("skyler", "Biallelic"),  # 0/1 & 0/1
-            ("flynn", "Biallelic"),  # 1/1 & 0/0
-            ("holly", "Biallelic"),  # 0/0 & 1/1
+            ("walt", "1"),
+            ("skyler", "1"),
+            ("flynn", "2"),
+            ("holly", "0"),
         ],
     )
-    def test_autosomal_recessive__with_default_predicate(
+    def test_ar_family__only_missense(
         self,
         patient_name: str,
         name: str,
         request: pytest.FixtureRequest,
     ):
         patient = request.getfixturevalue(patient_name)
-        predicate = autosomal_recessive()
+        is_missense = VariantPredicates.variant_effect(VariantEffect.MISSENSE_VARIANT, tx_id=TX_ID)
+        predicate = allele_count(
+            counts=((0,), (1,), (2,)),
+            target=is_missense,
+        )
 
         categorization = predicate.test(patient)
 
@@ -185,6 +117,18 @@ def test_autosomal_recessive__with_default_predicate(
 
         assert categorization.category.name == name
 
+    def test_eq_and_hash(self):
+        a = allele_count(counts=((0, 1), (2,)))
+        b = allele_count(counts=((0, 1), (2,)))
+
+        assert a == b
+        assert hash(a) == hash(b)
+
+    def test_summarize_groups(self):
+        a = allele_count(counts=((0, 1), (2,)))
+
+        assert a.summarize_groups() == "Allele count: 0 OR 1, 2"
+
 
 class TestAllelePredicates:
 
@@ -220,7 +164,7 @@ def test_monoallelic_predicate__general_stuff(
         
         gt_predicate = monoallelic_predicate(is_missense, is_synonymous)
         
-        assert gt_predicate.display_question() == 'Allele group: A, B'
+        assert gt_predicate.summarize_groups() == 'Allele group: A, B'
 
     @pytest.mark.parametrize(
         "individual_name,expected_name",
@@ -255,36 +199,7 @@ def test_biallelic_predicate__general_stuff(
         
         gt_predicate = biallelic_predicate(is_missense, is_synonymous)
         
-        assert gt_predicate.display_question() == 'Allele group: A/A, A/B, B/B'
-
-    @pytest.mark.parametrize(
-        "partitions, msg",
-        [
-            (((0,), (0,), (2,)), "partition (0,) was present 2!=1 times"),
-            (((0, 1), (1, 2,)), "element 1 was present 2!=1 times"),
-            (((0, 1), (1, 0)), "element 0 was present 2!=1 times, element 1 was present 2!=1 times"),
-            (((0, 1, 1), (1, 1, 2,)), "element 1 was present 4!=1 times"),
-            (((0.1,), (1,), (2,)), "Each partition index must be a non-negative int"),
-            (((0, 1, 2),), "At least 2 partitions must be provided"),
-        ]
-    )
-    def test_biallelic_predicate__invalid_partitions(
-        self,
-        partitions: typing.Sequence[typing.Sequence[int]],
-        msg: str,
-    ):
-        is_missense = VariantPredicates.variant_effect(VariantEffect.MISSENSE_VARIANT, TX_ID)
-        is_synonymous = VariantPredicates.variant_effect(VariantEffect.SYNONYMOUS_VARIANT, TX_ID)
-        
-        with pytest.raises(ValueError) as e:
-            biallelic_predicate(
-                a_predicate=is_missense,
-                b_predicate=is_synonymous,
-                a_label="A", b_label="B",
-                partitions=partitions
-            )
-
-        assert e.value.args == (msg,)
+        assert gt_predicate.summarize_groups() == 'Allele group: A/A, A/B, B/B'
 
 
 class TestSexPredicate:
@@ -303,9 +218,10 @@ def test_sex_predicate(
         assert gt_predicate.test(jane) == female
         assert gt_predicate.test(miffy) is None
 
-    def test_get_question(self):
+    def test_summarize_groups(self):
         gt_predicate = sex_predicate()
-        assert gt_predicate.display_question() == 'Sex of the individual: FEMALE, MALE'
+
+        assert gt_predicate.summarize_groups() == "Sex: FEMALE, MALE"
 
     @staticmethod
     def make_patient(label: str, sex: Sex) -> Patient:
diff --git a/tests/analysis/predicate/genotype/test_predicates.py b/tests/analysis/predicate/genotype/test_predicates.py
index 60ce0985..d23c6682 100644
--- a/tests/analysis/predicate/genotype/test_predicates.py
+++ b/tests/analysis/predicate/genotype/test_predicates.py
@@ -1,8 +1,17 @@
 import pytest
 
-from gpsea.analysis.predicate.genotype import VariantPredicates, ProteinPredicates
-from gpsea.model import *
-from gpsea.model.genome import *
+from gpsea.analysis.predicate.genotype import VariantPredicates
+from gpsea.model import (
+    Cohort,
+    FeatureInfo,
+    FeatureType,
+    ProteinFeature,
+    ProteinMetadata,
+    Variant,
+    VariantClass,
+    VariantEffect,
+)
+from gpsea.model.genome import Region
 
 
 class TestVariantPredicates:
@@ -114,8 +123,8 @@ def test_is_large_imprecise_sv(
     ):
         predicate = VariantPredicates.is_large_imprecise_sv()
 
-        assert predicate.test(missense_variant) == False
-        assert predicate.test(structural_variant) == True
+        assert predicate.test(missense_variant) is False
+        assert predicate.test(structural_variant) is True
 
     def test_is_structural_predicate(
         self,
@@ -124,8 +133,8 @@ def test_is_structural_predicate(
     ):
         predicate = VariantPredicates.is_structural_variant()
 
-        assert predicate.test(missense_variant) == False
-        assert predicate.test(structural_variant) == True
+        assert predicate.test(missense_variant) is False
+        assert predicate.test(structural_variant) is True
 
     def test_structural_type(
         self,
@@ -134,8 +143,8 @@ def test_structural_type(
     ):
         predicate = VariantPredicates.structural_type('SO:1000029')
 
-        assert predicate.test(missense_variant) == False
-        assert predicate.test(structural_variant) == True
+        assert predicate.test(missense_variant) is False
+        assert predicate.test(structural_variant) is True
 
     def test_variant_class(
         self,
@@ -143,7 +152,7 @@ def test_variant_class(
     ):
         predicate = VariantPredicates.variant_class(VariantClass.SNV)
 
-        assert predicate.test(missense_variant) == True
+        assert predicate.test(missense_variant) is True
 
     def test_change_length(
         self,
@@ -153,10 +162,10 @@ def test_change_length(
         predicate = VariantPredicates.change_length('==', 0)
         
         # variant is an SNP
-        assert predicate.test(missense_variant) == True
+        assert predicate.test(missense_variant) is True
 
         # structural_variant is an imprecise DEL, hence False
-        assert predicate.test(structural_variant) == False
+        assert predicate.test(structural_variant) is False
 
     def test_change_length_is_false_for_imprecise_SVs_no_matter_what(
         self,
@@ -164,7 +173,7 @@ def test_change_length_is_false_for_imprecise_SVs_no_matter_what(
     ):
         for op in ("<", "<=", "==", "!=", ">=", ">"):
             predicate = VariantPredicates.change_length(op, 0)
-            assert predicate.test(structural_variant) == False
+            assert predicate.test(structural_variant) is False
 
     def test_structural_deletion(
         self,
@@ -172,11 +181,29 @@ def test_structural_deletion(
     ):
         predicate = VariantPredicates.is_structural_deletion()
 
-        assert predicate.test(structural_variant) == True
+        assert predicate.test(structural_variant) is True
 
 
 class TestProteinPredicates:
 
+    @pytest.fixture(scope="class")
+    def protein_metadata(self) -> ProteinMetadata:
+        return ProteinMetadata(
+            protein_id="pt:xyz",
+            label="xyz_label",
+            protein_features=(
+                ProteinFeature.create(
+                    FeatureInfo(name="MOCK_REPEAT", region=Region(55, 80)),
+                    FeatureType.REPEAT,
+                ),
+                ProteinFeature.create(
+                    FeatureInfo(name="MOCK_DOMAIN", region=Region(30, 50)),
+                    FeatureType.DOMAIN,
+                ),
+            ),
+            protein_length=100,
+        )
+
     @pytest.mark.parametrize(
         'feature_type, expected',
         [
@@ -186,12 +213,15 @@ class TestProteinPredicates:
     )
     def test_protein_feature_type(
             self,
-            protein_predicates: ProteinPredicates,
             missense_variant: Variant,
             feature_type: FeatureType,
+            protein_metadata: ProteinMetadata,
             expected: bool,
     ):
-        predicate = protein_predicates.protein_feature_type(feature_type, tx_id='tx:xyz')
+        predicate = VariantPredicates.protein_feature_type(
+            feature_type=feature_type,
+            protein_metadata=protein_metadata,
+        )
 
         assert predicate.test(missense_variant) == expected
 
@@ -205,12 +235,15 @@ def test_protein_feature_type(
     )
     def test_protein_feature_id(
             self,
-            protein_predicates: ProteinPredicates,
             missense_variant: Variant,
             feature_id: str,
+            protein_metadata: ProteinMetadata,
             expected: bool,
     ):
-        predicate = protein_predicates.protein_feature(feature_id, tx_id='tx:xyz')
+        predicate = VariantPredicates.protein_feature(
+            feature_id=feature_id,
+            protein_metadata=protein_metadata,
+        )
 
         assert predicate.test(missense_variant) == expected
 
diff --git a/tests/analysis/pscore/test_counting_scorer.py b/tests/analysis/pscore/test_counting_scorer.py
index cf078e9d..8e1989bf 100644
--- a/tests/analysis/pscore/test_counting_scorer.py
+++ b/tests/analysis/pscore/test_counting_scorer.py
@@ -66,12 +66,16 @@ def test_a_patient(
 
         assert actual == expected
 
-    def test_get_question(
+    def test_summarize(
         self,
         counting_scorer: CountingPhenotypeScorer,
     ):
-        actual = counting_scorer.get_question()
-        assert actual == "How many of the query HPO terms (or their descendants) does the individual display"
+        lines = counting_scorer.summary().splitlines()
+        assert lines == [
+            'HPO Group Count',
+            'Assign a phenotype score that is equivalent to the count of present phenotypes '
+            + 'that are either an exact match to the query terms or their descendants',
+        ]
 
     def test_creating_scorer_with_term_and_ancestor_fails(
         self,
@@ -84,7 +88,10 @@ def test_creating_scorer_with_term_and_ancestor_fails(
             )
             CountingPhenotypeScorer.from_query_curies(hpo, query)
 
-        assert e.value.args[0] == "Both HP:0001250 and its ancestor term HP:0012638 were found in the query, but query terms must not include a term and its ancestor"
+        assert e.value.args[0] == (
+            "Both HP:0001250 and its ancestor term HP:0012638 were found in the query, "
+            "but query terms must not include a term and its ancestor"
+        )
 
     def test_creating_scorer_with_unknown_term_fails(
         self,
diff --git a/tests/analysis/pscore/test_pscore_api.py b/tests/analysis/pscore/test_pscore_api.py
new file mode 100644
index 00000000..c3bfafe3
--- /dev/null
+++ b/tests/analysis/pscore/test_pscore_api.py
@@ -0,0 +1,64 @@
+import random
+import pytest
+
+import pandas as pd
+
+from gpsea.analysis.predicate.genotype import GenotypePolyPredicate
+from gpsea.analysis.pscore import PhenotypeScoreAnalysisResult, PhenotypeScorer
+from gpsea.analysis.pscore.stats import MannWhitneyStatistic
+
+
+class TestPhenotypeScoreAnalysisResult:
+
+    @pytest.fixture(scope="class")
+    def phenotype_scorer(self) -> PhenotypeScorer:
+        return PhenotypeScorer.wrap_scoring_function(
+            func=lambda patient: random.random(),
+            name="Random phenotype scorer",
+        )
+
+    @pytest.fixture(scope="class")
+    def result(
+        self,
+        suox_gt_predicate: GenotypePolyPredicate,
+        phenotype_scorer: PhenotypeScorer,
+    ) -> PhenotypeScoreAnalysisResult:
+        data = pd.DataFrame(
+            data={
+                "patient_id": ["A", "B", "C"],
+                "genotype": [0, 1, None],
+                "phenotype": [
+                    10.,
+                    float('nan'),
+                    -4.,
+                ],
+            }
+        ).set_index("patient_id")
+        return PhenotypeScoreAnalysisResult(
+            gt_predicate=suox_gt_predicate,
+            phenotype=phenotype_scorer,
+            statistic=MannWhitneyStatistic(),
+            data=data,
+            pval=0.1234,
+        )
+
+    def test_properties(
+        self,
+        result: PhenotypeScoreAnalysisResult,
+    ):
+        assert tuple(result.gt_predicate.group_labels) == (
+            "0",
+            "1",
+        )
+        assert result.data.index.to_list() == ["A", "B", "C"]
+        assert result.pval == pytest.approx(0.1234)
+
+    def test_complete_records(
+        self,
+        result: PhenotypeScoreAnalysisResult,
+    ):
+        records = result.complete_records()
+
+        assert records.shape == (1, 2)
+        assert records.loc["A", "genotype"] == 0
+        assert records.loc["A", "phenotype"] == pytest.approx(10.)
diff --git a/tests/analysis/temporal/test_endpoint.py b/tests/analysis/temporal/test_endpoint.py
index 5cfc5f63..d18a10a3 100644
--- a/tests/analysis/temporal/test_endpoint.py
+++ b/tests/analysis/temporal/test_endpoint.py
@@ -1,4 +1,6 @@
 import hpotk
+
+
 import pytest
 
 from gpsea.model import Patient, Age, VitalStatus, Status, Disease, Phenotype
@@ -68,10 +70,15 @@ def test_compute_survival__unknown(
 
         assert survival is None
 
-    def test_display_question(self):
+    def test_summarize(self):
         endpoint = death(timeline="postnatal")
 
-        assert endpoint.display_question() == "Compute time until postnatal death"
+        lines = endpoint.summary().splitlines()
+
+        assert lines == [
+            'Age of death',
+            'Compute time until postnatal death',
+        ]
 
 
 class TestDiseaseOnset:
@@ -124,10 +131,15 @@ def test_compute_survival__off_target_disease(
 
         assert survival is None
 
-    def test_display_question(self):
+    def test_summarize(self):
         endpoint = disease_onset(disease_id="OMIM:100000")
+        
+        lines = endpoint.summary().splitlines()
 
-        assert endpoint.display_question() == "Compute time until postnatal diagnosis of OMIM:100000"
+        assert lines == [
+            'Onset of OMIM:100000',
+            'Compute time until OMIM:100000 onset',
+        ]
 
 
 class TestPhenotypeOnset:
@@ -183,10 +195,15 @@ def test_compute_survival__phenotyped(
         assert not survival.is_censored
         assert survival.value == pytest.approx(20.)  # Focal-onset seizure has the earliest onset
 
-    def test_display_question(
+    def test_summarize(
         self,
         hpo: hpotk.MinimalOntology,
     ):
         endpoint = hpo_onset(hpo, term_id="HP:0001250")  # Seizure
 
-        assert endpoint.display_question() == "Compute time until postnatal onset of Seizure"
+        lines = endpoint.summary().splitlines()
+
+        assert lines == [
+            'Onset of Seizure',
+            'Compute time until onset of Seizure',
+        ]
diff --git a/tests/analysis/temporal/test_api.py b/tests/analysis/temporal/test_temporal_api.py
similarity index 67%
rename from tests/analysis/temporal/test_api.py
rename to tests/analysis/temporal/test_temporal_api.py
index d2d547db..e9e869d6 100644
--- a/tests/analysis/temporal/test_api.py
+++ b/tests/analysis/temporal/test_temporal_api.py
@@ -4,6 +4,7 @@
 import hpotk
 import pytest
 
+import matplotlib.pyplot as plt
 import pandas as pd
 
 from gpsea.model import Cohort
@@ -14,7 +15,7 @@
     monoallelic_predicate,
 )
 from gpsea.analysis.temporal import SurvivalAnalysis, SurvivalAnalysisResult, Survival
-from gpsea.analysis.temporal.endpoint import hpo_onset
+from gpsea.analysis.temporal.endpoint import hpo_onset, death
 from gpsea.analysis.temporal.stats import LogRankTest
 
 
@@ -31,7 +32,7 @@ def umod_cohort(
 def umod_gt_predicate() -> GenotypePolyPredicate:
     in_exon_3 = VariantPredicates.exon(3, tx_id="NM_003361.4")
     return monoallelic_predicate(
-        a_predicate=in_exon_3, b_predicate=~in_exon_3, 
+        a_predicate=in_exon_3, b_predicate=~in_exon_3,
         a_label="Exon 3", b_label="Other exon",
     )
 
@@ -73,17 +74,24 @@ def result(
     ) -> SurvivalAnalysisResult:
         data = pd.DataFrame(
             data={
-                "patient_id": ["A", "B", "C"],
-                "genotype": [0, 1, None],
-                "survival": [
+                "patient_id": ["A", "B", "C", "D", "E", "F", "G", "H"],
+                "genotype": [0, 1, None, 0, 0, 1, 1, 1],
+                "phenotype": [
                     Survival(value=12.3, is_censored=False),
                     None,
                     Survival(value=45.6, is_censored=True),
+                    Survival(value=22.6, is_censored=True),
+                    Survival(value=45.6, is_censored=False),
+                    Survival(value=30.0, is_censored=False),
+                    Survival(value=68.0, is_censored=False),
+                    Survival(value=95.0, is_censored=True),
                 ],
             }
         ).set_index("patient_id")
         return SurvivalAnalysisResult(
             gt_predicate=umod_gt_predicate,
+            endpoint=death(),
+            statistic=LogRankTest(),
             data=data,
             pval=0.1234,
         )
@@ -92,11 +100,11 @@ def test_properties(
         self,
         result: SurvivalAnalysisResult,
     ):
-        assert tuple(result.gt_predicate.get_category_names()) == (
+        assert tuple(result.gt_predicate.group_labels) == (
             "Exon 3",
             "Other exon",
         )
-        assert result.data.index.to_list() == ["A", "B", "C"]
+        assert result.data.index.to_list() == ["A", "B", "C", "D", "E", "F", "G", "H"]
         assert result.pval == pytest.approx(0.1234)
 
     def test_complete_records(
@@ -105,6 +113,17 @@ def test_complete_records(
     ):
         records = result.complete_records()
 
-        assert records.shape == (1, 2)
+        assert records.shape == (6, 2)
         assert records.loc["A", "genotype"] == 0
-        assert records.loc["A", "survival"] == Survival(value=12.3, is_censored=False)
+        assert records.loc["A", "phenotype"] == Survival(value=12.3, is_censored=False)
+
+    @pytest.mark.skip("Only for manual debugging")
+    def test_plot_kaplan_meier(
+        self,
+        result: SurvivalAnalysisResult,
+    ):
+        fig, ax = plt.subplots(figsize=(6, 4), dpi=120)
+        result.plot_kaplan_meier_curves(
+            ax=ax,
+        )
+        fig.savefig("example-kaplan-meier.png")
diff --git a/tests/conftest.py b/tests/conftest.py
index 0ddf7393..07e9dc6f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,16 +3,32 @@
 import typing
 
 import hpotk
-import numpy as np
-import pandas as pd
 import pytest
 
-from gpsea.analysis.mtc_filter import PhenotypeMtcResult
-from gpsea.analysis.pcats import HpoTermAnalysisResult
-from gpsea.analysis.predicate.genotype import GenotypePolyPredicate, VariantPredicates, autosomal_dominant
+from gpsea.analysis.predicate.genotype import (
+    GenotypePolyPredicate,
+    VariantPredicates,
+    allele_count,
+)
 from gpsea.analysis.predicate.phenotype import PhenotypePolyPredicate, HpoPredicate
 from gpsea.io import GpseaJSONDecoder
-from gpsea.model import *
+from gpsea.model import (
+    Cohort,
+    Disease,
+    Genotype,
+    Genotypes,
+    Patient,
+    Phenotype,
+    ProteinMetadata,
+    SampleLabels,
+    Sex,
+    TranscriptAnnotation,
+    TranscriptCoordinates,
+    Variant,
+    VariantCoordinates,
+    VariantEffect,
+    VariantInfo,
+)
 from gpsea.model.genome import GRCh38, GenomicRegion, Region, Strand, GenomeBuild
 from ._protein_test_service import ProteinTestMetadataService
 
@@ -24,7 +40,9 @@ def pytest_addoption(parser):
 
 
 def pytest_configure(config):
-    config.addinivalue_line("markers", "online: mark test that require internet access to run")
+    config.addinivalue_line(
+        "markers", "online: mark test that require internet access to run"
+    )
 
 
 def pytest_collection_modifyitems(config, items):
@@ -37,7 +55,7 @@ def pytest_collection_modifyitems(config, items):
             item.add_marker(skip_online)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def fpath_project_dir(fpath_test_dir: str) -> str:
     """
     Path to project folder, where `pyproject.toml`, `README.md`,
@@ -46,7 +64,7 @@ def fpath_project_dir(fpath_test_dir: str) -> str:
     return os.path.dirname(fpath_test_dir)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def fpath_test_dir() -> str:
     """
     Path to `tests` folder.
@@ -54,7 +72,7 @@ def fpath_test_dir() -> str:
     return os.path.dirname(os.path.abspath(__file__))
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def fpath_docs_dir(fpath_project_dir: str) -> str:
     """
     Path to `docs` folder.
@@ -62,43 +80,43 @@ def fpath_docs_dir(fpath_project_dir: str) -> str:
     return os.path.join(fpath_project_dir, "docs")
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def fpath_cohort_data_dir(fpath_docs_dir: str) -> str:
     return os.path.join(fpath_docs_dir, "cohort-data")
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def fpath_test_data_dir(fpath_test_dir: str) -> str:
-    return os.path.join(fpath_test_dir, 'test_data')
+    return os.path.join(fpath_test_dir, "test_data")
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def fpath_phenopacket_dir(fpath_test_data_dir: str) -> str:
-    return os.path.join(fpath_test_data_dir, 'phenopackets')
+    return os.path.join(fpath_test_data_dir, "phenopackets")
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def fpath_toy_hpo(fpath_test_data_dir: str) -> str:
-    return os.path.join(fpath_test_data_dir, 'hp.toy.json')
+    return os.path.join(fpath_test_data_dir, "hp.toy.json")
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def toy_hpo(fpath_toy_hpo: str) -> hpotk.MinimalOntology:
     return hpotk.load_minimal_ontology(fpath_toy_hpo)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def hpo(fpath_test_data_dir: str) -> hpotk.MinimalOntology:
-    fpath_hpo = os.path.join(fpath_test_data_dir, 'hp.v2024-04-26.json.gz')
+    fpath_hpo = os.path.join(fpath_test_data_dir, "hp.v2024-04-26.json.gz")
     return hpotk.load_minimal_ontology(fpath_hpo)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def validation_runner(hpo: hpotk.MinimalOntology) -> hpotk.validate.ValidationRunner:
     validators = (
         hpotk.validate.ObsoleteTermIdsValidator(hpo),
         hpotk.validate.AnnotationPropagationValidator(hpo),
-        hpotk.validate.PhenotypicAbnormalityValidator(hpo)
+        hpotk.validate.PhenotypicAbnormalityValidator(hpo),
     )
     return hpotk.validate.ValidationRunner(validators)
 
@@ -109,46 +127,46 @@ def make_region(contig: str, start: int, end: int) -> GenomicRegion:
     return GenomicRegion(a_contig, start, end, Strand.POSITIVE)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def protein_test_service() -> ProteinTestMetadataService:
     return ProteinTestMetadataService.create()
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def fpath_suox_cohort(
-        fpath_test_data_dir: str,
+    fpath_test_data_dir: str,
 ) -> str:
-    return os.path.join(fpath_test_data_dir, 'SUOX.json')
+    return os.path.join(fpath_test_data_dir, "SUOX.json")
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def suox_cohort(
-        fpath_suox_cohort: str,
+    fpath_suox_cohort: str,
 ) -> Cohort:
     with open(fpath_suox_cohort) as fh:
         return json.load(fh, cls=GpseaJSONDecoder)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def suox_mane_tx_id() -> str:
-    return 'NM_001032386.2'
+    return "NM_001032386.2"
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def suox_gt_predicate(
     suox_mane_tx_id: str,
 ) -> GenotypePolyPredicate:
-    return autosomal_dominant(
-        variant_predicate=VariantPredicates.variant_effect(
-            effect=VariantEffect.MISSENSE_VARIANT,
-            tx_id=suox_mane_tx_id
-        )
+    return allele_count(
+        counts=((0,), (1,)),
+        target=VariantPredicates.variant_effect(
+            effect=VariantEffect.MISSENSE_VARIANT, tx_id=suox_mane_tx_id
+        ),
     )
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def suox_pheno_predicates(
-        hpo: hpotk.MinimalOntology,
+    hpo: hpotk.MinimalOntology,
 ) -> typing.Sequence[PhenotypePolyPredicate[hpotk.TermId]]:
     """
     Get predicates for test for presence of 5 HPO terms:
@@ -159,289 +177,372 @@ def suox_pheno_predicates(
     return (
         HpoPredicate(
             hpo=hpo,
-            query=hpotk.TermId.from_curie('HP:0001250'),  # Seizure
+            query=hpotk.TermId.from_curie("HP:0001250"),  # Seizure
         ),
         HpoPredicate(
             hpo=hpo,
-            query=hpotk.TermId.from_curie('HP:0001083'),  # Ectopia lentis
+            query=hpotk.TermId.from_curie("HP:0001083"),  # Ectopia lentis
         ),
         HpoPredicate(
             hpo=hpo,
-            query=hpotk.TermId.from_curie('HP:0032350'),  # Sulfocysteinuria
+            query=hpotk.TermId.from_curie("HP:0032350"),  # Sulfocysteinuria
         ),
         HpoPredicate(
             hpo=hpo,
-            query=hpotk.TermId.from_curie('HP:0012758'),  # Neurodevelopmental delay
+            query=hpotk.TermId.from_curie("HP:0012758"),  # Neurodevelopmental delay
         ),
         HpoPredicate(
             hpo=hpo,
-            query=hpotk.TermId.from_curie('HP:0001276'),  # Hypertonia
+            query=hpotk.TermId.from_curie("HP:0001276"),  # Hypertonia
         ),
     )
 
 
-@pytest.fixture
-def hpo_result(
-    suox_pheno_predicates: typing.Sequence[PhenotypePolyPredicate[hpotk.TermId]],
-    suox_gt_predicate: GenotypePolyPredicate,
-) -> HpoTermAnalysisResult:
-    return HpoTermAnalysisResult(
-        pheno_predicates=suox_pheno_predicates,
-        n_usable=(40, 20, 30, 10, 100),
-        all_counts=tuple(
-            make_count_df(count, suox_gt_predicate, ph_pred)
-            for count, ph_pred in zip(
-                [
-                    (10, 5, 15, 10),
-                    (5, 2, 8, 5),
-                    (10, 5, 5, 10),
-                    (2, 3, 2, 3),
-                    (10, 25, 35, 30),
-                ],
-                suox_pheno_predicates,
-            )
-        ),
-        pvals=(0.01, 0.04, 0.3, 0.2, 0.7),
-        corrected_pvals=(0.04, 0.1, 0.7, 0.5, 1.0),
-        gt_predicate=suox_gt_predicate,
-        mtc_filter_name="MTC filter name",
-        mtc_filter_results=(
-            PhenotypeMtcResult.ok(),
-            PhenotypeMtcResult.ok(),
-            PhenotypeMtcResult.ok(),
-            PhenotypeMtcResult.ok(),
-            PhenotypeMtcResult.ok(),
-        ),
-        mtc_name="MTC name",
-    )
-
-
-def make_count_df(
-    counts: typing.Tuple[int, int, int, int],
-    gt_predicate: GenotypePolyPredicate,
-    ph_predicate: PhenotypePolyPredicate[hpotk.TermId],
-) -> pd.DataFrame:
-    rows = tuple(ph_predicate.get_categories())
-    cols = tuple(gt_predicate.get_categories())
-    data = np.array(counts).reshape((len(rows), len(cols)))
-    return pd.DataFrame(
-        data=data,
-        index=pd.Index(rows),
-        columns=pd.Index(cols),
-    )
-
-
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def fpath_suox_tx_coordinates(fpath_test_data_dir: str) -> str:
-    suox_mane_tx_id = 'NM_001032386.2'
-    return os.path.join(fpath_test_data_dir, f'SUOX-{suox_mane_tx_id}.json')
+    suox_mane_tx_id = "NM_001032386.2"
+    return os.path.join(fpath_test_data_dir, f"SUOX-{suox_mane_tx_id}.json")
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def suox_mane_tx_coordinates(
-        fpath_suox_tx_coordinates: str,
+    fpath_suox_tx_coordinates: str,
 ) -> TranscriptCoordinates:
     with open(fpath_suox_tx_coordinates) as fh:
         return json.load(fh, cls=GpseaJSONDecoder)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def fpath_suox_protein_metadata(fpath_test_data_dir: str) -> str:
-    suox_mane_tx_protein_id = 'NP_001027558.1'
-    return os.path.join(fpath_test_data_dir, f'SUOX-{suox_mane_tx_protein_id}.json')
+    suox_mane_tx_protein_id = "NP_001027558.1"
+    return os.path.join(fpath_test_data_dir, f"SUOX-{suox_mane_tx_protein_id}.json")
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def suox_protein_metadata(
-        fpath_suox_protein_metadata: str,
+    fpath_suox_protein_metadata: str,
 ) -> ProteinMetadata:
     with open(fpath_suox_protein_metadata) as fh:
         return json.load(fh, cls=GpseaJSONDecoder)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def toy_cohort(
-        test_phenotypes: typing.Mapping[str, Phenotype],
-        test_diseases: typing.Mapping[str, Disease],
+    test_phenotypes: typing.Mapping[str, Phenotype],
+    test_diseases: typing.Mapping[str, Disease],
 ) -> Cohort:
-    prot_id = 'NP_037407.4'
+    prot_id = "NP_037407.4"
 
     dup = Variant(
         variant_info=VariantInfo(
-            variant_coordinates=VariantCoordinates(make_region("16", 89279849, 89279850), ref='G', alt='GC', change_length=1)
+            variant_coordinates=VariantCoordinates(
+                make_region("16", 89279849, 89279850),
+                ref="G",
+                alt="GC",
+                change_length=1,
+            )
         ),
         tx_annotations=[
             TranscriptAnnotation(
-                'ANKRD11', 'NM_013275.6', 'NM_013275.6:c.6691dup', False, [VariantEffect.FRAMESHIFT_VARIANT], [9],
-                prot_id, "NP_001243112.1:p.Ala2231GlyfsTer29", Region(2230, 2231))
+                "ANKRD11",
+                "NM_013275.6",
+                "NM_013275.6:c.6691dup",
+                False,
+                [VariantEffect.FRAMESHIFT_VARIANT],
+                [9],
+                prot_id,
+                "NP_001243112.1:p.Ala2231GlyfsTer29",
+                Region(2230, 2231),
+            )
         ],
-        genotypes=Genotypes.from_mapping({SampleLabels('HetSingleVar'): Genotype.HETEROZYGOUS}),
+        genotypes=Genotypes.from_mapping(
+            {SampleLabels("HetSingleVar"): Genotype.HETEROZYGOUS}
+        ),
     )
     indel = Variant(
         variant_info=VariantInfo(
-            variant_coordinates=VariantCoordinates(make_region("16", 89284600, 89284602), ref='GG', alt='A', change_length=-1),
+            variant_coordinates=VariantCoordinates(
+                make_region("16", 89284600, 89284602),
+                ref="GG",
+                alt="A",
+                change_length=-1,
+            ),
         ),
         tx_annotations=[
             TranscriptAnnotation(
-                'ANKRD11', 'NM_013275.6', 'NM_013275.6:c.1940_1941delinsT', False, [VariantEffect.FRAMESHIFT_VARIANT], [9], 
-                prot_id, "NP_001243111.1:p.Ser647LeufsTer6", Region(646, 647))
+                "ANKRD11",
+                "NM_013275.6",
+                "NM_013275.6:c.1940_1941delinsT",
+                False,
+                [VariantEffect.FRAMESHIFT_VARIANT],
+                [9],
+                prot_id,
+                "NP_001243111.1:p.Ser647LeufsTer6",
+                Region(646, 647),
+            )
         ],
-        genotypes=Genotypes.from_mapping({SampleLabels('HetDoubleVar1'): Genotype.HETEROZYGOUS}),
+        genotypes=Genotypes.from_mapping(
+            {SampleLabels("HetDoubleVar1"): Genotype.HETEROZYGOUS}
+        ),
     )
     snv_stop_gain = Variant(
         variant_info=VariantInfo(
-            variant_coordinates=VariantCoordinates(make_region("16", 89280751, 89280752), ref='G', alt='T', change_length=0)
+            variant_coordinates=VariantCoordinates(
+                make_region("16", 89280751, 89280752),
+                ref="G",
+                alt="T",
+                change_length=0,
+            )
         ),
         tx_annotations=[
             TranscriptAnnotation(
-                'ANKRD11', 'NM_013275.6', 'NM_013275.6:c.5790C>A', False, [VariantEffect.STOP_GAINED], [9], 
-                prot_id, "NP_037407.4:p.Tyr1930Ter", Region(1929, 1930))
+                "ANKRD11",
+                "NM_013275.6",
+                "NM_013275.6:c.5790C>A",
+                False,
+                [VariantEffect.STOP_GAINED],
+                [9],
+                prot_id,
+                "NP_037407.4:p.Tyr1930Ter",
+                Region(1929, 1930),
+            )
         ],
-        genotypes=Genotypes.from_mapping({SampleLabels('HetDoubleVar1'): Genotype.HETEROZYGOUS}),
+        genotypes=Genotypes.from_mapping(
+            {SampleLabels("HetDoubleVar1"): Genotype.HETEROZYGOUS}
+        ),
     )
     snv_missense = Variant(
         variant_info=VariantInfo(
-            variant_coordinates=VariantCoordinates(make_region("16", 89275127, 89275128), ref='G', alt='A', change_length=0)
+            variant_coordinates=VariantCoordinates(
+                make_region("16", 89275127, 89275128),
+                ref="G",
+                alt="A",
+                change_length=0,
+            )
         ),
         tx_annotations=[
             TranscriptAnnotation(
-                'ANKRD11', 'NM_013275.6', 'NM_013275.6:c.7534C>T', False, [VariantEffect.MISSENSE_VARIANT], [10], 
-                prot_id, "NP_037407.4:p.Arg2512Trp", Region(2511, 2512))
+                "ANKRD11",
+                "NM_013275.6",
+                "NM_013275.6:c.7534C>T",
+                False,
+                [VariantEffect.MISSENSE_VARIANT],
+                [10],
+                prot_id,
+                "NP_037407.4:p.Arg2512Trp",
+                Region(2511, 2512),
+            )
         ],
-        genotypes=Genotypes.from_mapping({SampleLabels('HetDoubleVar2'): Genotype.HETEROZYGOUS}),
+        genotypes=Genotypes.from_mapping(
+            {SampleLabels("HetDoubleVar2"): Genotype.HETEROZYGOUS}
+        ),
     )
     del_frameshift = Variant(
         variant_info=VariantInfo(
-            variant_coordinates=VariantCoordinates(make_region("16", 89279707, 89279725), ref='AGTGTTCGGGGCGGGGCC', alt='A', change_length=-17)
+            variant_coordinates=VariantCoordinates(
+                make_region("16", 89279707, 89279725),
+                ref="AGTGTTCGGGGCGGGGCC",
+                alt="A",
+                change_length=-17,
+            )
         ),
         tx_annotations=[
             TranscriptAnnotation(
-                'ANKRD11', 'NM_013275.6', 'NM_013275.6:c.6817_6833del', False, [VariantEffect.FRAMESHIFT_VARIANT], [9], 
-                prot_id, "NP_037407.4:p.Gly2273CysfsTer17", Region(2272, 2278),
+                "ANKRD11",
+                "NM_013275.6",
+                "NM_013275.6:c.6817_6833del",
+                False,
+                [VariantEffect.FRAMESHIFT_VARIANT],
+                [9],
+                prot_id,
+                "NP_037407.4:p.Gly2273CysfsTer17",
+                Region(2272, 2278),
             )
         ],
-        genotypes=Genotypes.from_mapping({SampleLabels('HetDoubleVar2'): Genotype.HETEROZYGOUS}),
+        genotypes=Genotypes.from_mapping(
+            {SampleLabels("HetDoubleVar2"): Genotype.HETEROZYGOUS}
+        ),
     )
     del_small = Variant(
         variant_info=VariantInfo(
-            variant_coordinates=VariantCoordinates(make_region("16", 89279457, 89279459), ref='TG', alt='T', change_length=-1)
+            variant_coordinates=VariantCoordinates(
+                make_region("16", 89279457, 89279459),
+                ref="TG",
+                alt="T",
+                change_length=-1,
+            )
         ),
         tx_annotations=[
             TranscriptAnnotation(
-                'ANKRD11', 'NM_013275.6', 'NM_013275.6:c.7083del', False, [VariantEffect.FRAMESHIFT_VARIANT], [9],
-                prot_id, "NP_037407.4:p.Thr2362ProfsTer39", Region(2360, 2362))
+                "ANKRD11",
+                "NM_013275.6",
+                "NM_013275.6:c.7083del",
+                False,
+                [VariantEffect.FRAMESHIFT_VARIANT],
+                [9],
+                prot_id,
+                "NP_037407.4:p.Thr2362ProfsTer39",
+                Region(2360, 2362),
+            )
         ],
-        genotypes=Genotypes.from_mapping({SampleLabels('HomoVar'): Genotype.HOMOZYGOUS_ALTERNATE}),
+        genotypes=Genotypes.from_mapping(
+            {SampleLabels("HomoVar"): Genotype.HOMOZYGOUS_ALTERNATE}
+        ),
     )
     del_large = Variant(
         variant_info=VariantInfo(
-            variant_coordinates=VariantCoordinates(make_region("16", 89_190_070, 89_439_815), ref='N', alt='<DEL>', change_length=-249_745)
+            variant_coordinates=VariantCoordinates(
+                make_region("16", 89_190_070, 89_439_815),
+                ref="N",
+                alt="<DEL>",
+                change_length=-249_745,
+            )
         ),
         tx_annotations=[
             TranscriptAnnotation(
-                'ANKRD11', 'NM_013275.6', None, False, 
-                [VariantEffect.STOP_LOST, VariantEffect.FEATURE_TRUNCATION, VariantEffect.CODING_SEQUENCE_VARIANT, VariantEffect.FIVE_PRIME_UTR_VARIANT, VariantEffect.THREE_PRIME_UTR_VARIANT, VariantEffect.INTRON_VARIANT], 
+                "ANKRD11",
+                "NM_013275.6",
+                None,
+                False,
+                [
+                    VariantEffect.STOP_LOST,
+                    VariantEffect.FEATURE_TRUNCATION,
+                    VariantEffect.CODING_SEQUENCE_VARIANT,
+                    VariantEffect.FIVE_PRIME_UTR_VARIANT,
+                    VariantEffect.THREE_PRIME_UTR_VARIANT,
+                    VariantEffect.INTRON_VARIANT,
+                ],
                 [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
-                prot_id, None, None
+                prot_id,
+                None,
+                None,
             )
         ],
-        genotypes=Genotypes.from_mapping({SampleLabels('LargeCNV'): Genotype.HETEROZYGOUS}))
+        genotypes=Genotypes.from_mapping(
+            {SampleLabels("LargeCNV"): Genotype.HETEROZYGOUS}
+        ),
+    )
 
     patients = (
         Patient.from_raw_parts(
-            SampleLabels('HetSingleVar'),
+            SampleLabels("HetSingleVar"),
             sex=Sex.UNKNOWN_SEX,
             age=None,
             vital_status=None,
             phenotypes=(
-                test_phenotypes['arachnodactyly_T'],
-                test_phenotypes['spasticity_F'],
-                test_phenotypes['focal_clonic_seizure_T']
+                test_phenotypes["arachnodactyly_T"],
+                test_phenotypes["spasticity_F"],
+                test_phenotypes["focal_clonic_seizure_T"],
             ),
             measurements=(),
             variants=(dup,),
-            diseases=(test_diseases['KBG_T'],)
+            diseases=(test_diseases["KBG_T"],),
         ),
         Patient.from_raw_parts(
-            SampleLabels('HetDoubleVar1'),
+            SampleLabels("HetDoubleVar1"),
             sex=Sex.UNKNOWN_SEX,
             age=None,
             vital_status=None,
             phenotypes=(
-                test_phenotypes['arachnodactyly_T'], test_phenotypes['seizure_T'], test_phenotypes['spasticity_T'],
+                test_phenotypes["arachnodactyly_T"],
+                test_phenotypes["seizure_T"],
+                test_phenotypes["spasticity_T"],
             ),
             measurements=(),
             variants=(indel, snv_stop_gain),
-            diseases=(test_diseases['KBG_T'],)
+            diseases=(test_diseases["KBG_T"],),
         ),
         Patient.from_raw_parts(
-            SampleLabels('HetDoubleVar2'),
+            SampleLabels("HetDoubleVar2"),
             sex=Sex.UNKNOWN_SEX,
             age=None,
             vital_status=None,
             phenotypes=(
-                test_phenotypes['arachnodactyly_F'], test_phenotypes['spasticity_T'], test_phenotypes['seizure_T'],
+                test_phenotypes["arachnodactyly_F"],
+                test_phenotypes["spasticity_T"],
+                test_phenotypes["seizure_T"],
             ),
             measurements=(),
             variants=(snv_missense, del_frameshift),
-            diseases=(test_diseases['KBG_T'],)
+            diseases=(test_diseases["KBG_T"],),
         ),
         Patient.from_raw_parts(
-            SampleLabels('HomoVar'),
+            SampleLabels("HomoVar"),
             sex=Sex.UNKNOWN_SEX,
             age=None,
             vital_status=None,
             phenotypes=(
-                test_phenotypes['arachnodactyly_T'], test_phenotypes['spasticity_T'], test_phenotypes['seizure_T'],
+                test_phenotypes["arachnodactyly_T"],
+                test_phenotypes["spasticity_T"],
+                test_phenotypes["seizure_T"],
             ),
             measurements=(),
             variants=(del_small,),
-            diseases=()
+            diseases=(),
         ),
         Patient.from_raw_parts(
-            SampleLabels('LargeCNV'),
+            SampleLabels("LargeCNV"),
             sex=Sex.UNKNOWN_SEX,
             age=None,
             vital_status=None,
             phenotypes=(
-                test_phenotypes['arachnodactyly_T'], test_phenotypes['spasticity_T'], test_phenotypes['seizure_F'],
+                test_phenotypes["arachnodactyly_T"],
+                test_phenotypes["spasticity_T"],
+                test_phenotypes["seizure_F"],
             ),
             measurements=(),
             variants=(del_large,),
-            diseases=()
+            diseases=(),
         ),
     )
 
     return Cohort.from_patients(patients)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def test_phenotypes() -> typing.Mapping[str, Phenotype]:
     return {
-        'arachnodactyly_T': Phenotype.from_raw_parts(hpotk.TermId.from_curie('HP:0001166'), True),
-        'seizure_T': Phenotype.from_raw_parts(hpotk.TermId.from_curie('HP:0001250'), True),
-        'focal_clonic_seizure_T': Phenotype.from_raw_parts(
-            hpotk.TermId.from_curie('HP:0002266'), True,
+        "arachnodactyly_T": Phenotype.from_raw_parts(
+            hpotk.TermId.from_curie("HP:0001166"), True
         ),
-        'spasticity_T': Phenotype.from_raw_parts(hpotk.TermId.from_curie('HP:0001257'), True),
-
-        'arachnodactyly_F': Phenotype.from_raw_parts(hpotk.TermId.from_curie('HP:0001166'), False),
-        'seizure_F': Phenotype.from_raw_parts(hpotk.TermId.from_curie('HP:0001250'), False),
-        'spasticity_F': Phenotype.from_raw_parts(hpotk.TermId.from_curie('HP:0001257'), False),
-        'focal_clonic_seizure_F': Phenotype.from_raw_parts(
-            hpotk.TermId.from_curie('HP:0002266'), False,
+        "seizure_T": Phenotype.from_raw_parts(
+            hpotk.TermId.from_curie("HP:0001250"), True
+        ),
+        "focal_clonic_seizure_T": Phenotype.from_raw_parts(
+            hpotk.TermId.from_curie("HP:0002266"),
+            True,
+        ),
+        "spasticity_T": Phenotype.from_raw_parts(
+            hpotk.TermId.from_curie("HP:0001257"), True
+        ),
+        "arachnodactyly_F": Phenotype.from_raw_parts(
+            hpotk.TermId.from_curie("HP:0001166"), False
+        ),
+        "seizure_F": Phenotype.from_raw_parts(
+            hpotk.TermId.from_curie("HP:0001250"), False
+        ),
+        "spasticity_F": Phenotype.from_raw_parts(
+            hpotk.TermId.from_curie("HP:0001257"), False
+        ),
+        "focal_clonic_seizure_F": Phenotype.from_raw_parts(
+            hpotk.TermId.from_curie("HP:0002266"),
+            False,
         ),
     }
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def test_diseases() -> typing.Mapping[str, Disease]:
     return {
-        'KBG_T': Disease.from_raw_parts(hpotk.TermId.from_curie("OMIM:148050"), "KBG syndrome", True),
-        'KBG_F': Disease.from_raw_parts(hpotk.TermId.from_curie("OMIM:148050"), "KBG syndrome", False),
+        "KBG_T": Disease.from_raw_parts(
+            hpotk.TermId.from_curie("OMIM:148050"), "KBG syndrome", True
+        ),
+        "KBG_F": Disease.from_raw_parts(
+            hpotk.TermId.from_curie("OMIM:148050"), "KBG syndrome", False
+        ),
     }
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def genome_build() -> GenomeBuild:
     return GRCh38
diff --git a/tests/preprocessing/conftest.py b/tests/preprocessing/conftest.py
index 28b6dca6..5ff2883b 100644
--- a/tests/preprocessing/conftest.py
+++ b/tests/preprocessing/conftest.py
@@ -2,6 +2,7 @@
 
 import pytest
 
+
 @pytest.fixture(scope='session')
 def fpath_preprocessing_data_dir() -> str:
     parent = os.path.dirname(__file__)
diff --git a/tests/preprocessing/test_patient_and_cohort_creator.py b/tests/preprocessing/test_patient_and_cohort_creator.py
index 0ac8fbdc..f8cf918d 100644
--- a/tests/preprocessing/test_patient_and_cohort_creator.py
+++ b/tests/preprocessing/test_patient_and_cohort_creator.py
@@ -102,7 +102,10 @@ def test_cohort_creator(
         outfile = io.StringIO()
         results.summarize(outfile)
         
-        actual_lines = outfile.getvalue().split(os.linesep)
+        actual_lines = outfile.getvalue().splitlines(keepends=False)
 
-        expected = " Patient ID/s Pat_1[PMID_12345], Pat_2[PMID_67890] have a duplicate. Please verify every patient has an unique ID."
+        expected = (
+            " ·Patient ID/s Pat_1[PMID_12345], Pat_2[PMID_67890] have a duplicate. "
+            "Please verify every patient has an unique ID."
+        )
         assert expected in actual_lines
diff --git a/tests/preprocessing/test_phenopacket.py b/tests/preprocessing/test_phenopacket.py
index f4630186..6c1a2e27 100644
--- a/tests/preprocessing/test_phenopacket.py
+++ b/tests/preprocessing/test_phenopacket.py
@@ -3,6 +3,9 @@
 import hpotk
 import pytest
 
+from stairval import Level
+from stairval.notepad import create_notepad
+
 from google.protobuf.json_format import Parse
 from phenopackets.schema.v2.core.interpretation_pb2 import GenomicInterpretation
 from phenopackets.schema.v2.phenopackets_pb2 import Phenopacket
@@ -21,7 +24,6 @@
     ImpreciseSvFunctionalAnnotator,
     DefaultImpreciseSvFunctionalAnnotator,
 )
-from gpsea.preprocessing import Level
 from gpsea.preprocessing import PhenopacketPatientCreator
 from gpsea.preprocessing import VVMultiCoordinateService
 
@@ -208,8 +210,10 @@ def test_phenopacket_patient_creator(
         phenopacket: Phenopacket,
         patient_creator: PhenopacketPatientCreator,
     ):
-        notepad = patient_creator.prepare_notepad("A phenopacket")
+        notepad = create_notepad("A phenopacket")
         patient = patient_creator.process(phenopacket, notepad)
+        
+        assert patient is not None
 
         # No issues
         assert not notepad.has_errors_or_warnings(include_subsections=True)
@@ -295,7 +299,7 @@ def test_individual_with_no_genotype(
         pp.CopyFrom(phenopacket)
         del pp.interpretations[:]  # clear variants
 
-        notepad = patient_creator.prepare_notepad("no-gt")
+        notepad = create_notepad("no-gt")
         
         _ = patient_creator.process(pp=pp, notepad=notepad)
         
@@ -322,7 +326,7 @@ def test_individual_with_no_phenotype(
         del pp.diseases[:]
         del pp.measurements[:]
 
-        notepad = patient_creator.prepare_notepad("no-gt")
+        notepad = create_notepad("no-gt")
         
         _ = patient_creator.process(pp=pp, notepad=notepad)
         
diff --git a/tests/test_predicates.py b/tests/test_predicates.py
index 71308b8e..43e8d8d2 100644
--- a/tests/test_predicates.py
+++ b/tests/test_predicates.py
@@ -1,11 +1,8 @@
 import hpotk
 import pytest
 
-from gpsea.analysis.predicate import PatientCategory, PatientCategories
 from gpsea.analysis.predicate.phenotype import HpoPredicate, DiseasePresencePredicate
-from gpsea.analysis.predicate.genotype import *
 from gpsea.model import Cohort, Patient
-from gpsea.preprocessing import ProteinMetadataService
 
 
 def find_patient(pat_id: str, cohort: Cohort) -> Patient:
@@ -25,15 +22,15 @@ class TestHpoPredicate:
                                  # Test exact match
                                  ('HP:0001166',  # Arachnodactyly
                                   'HetSingleVar',
-                                  PatientCategories.YES),
+                                  "Yes"),
                                  # Test inferred annotations
                                  ('HP:0001250',  # Seizure
                                   'HetSingleVar',
-                                  PatientCategories.YES),
+                                  "Yes"),
                                  # Test excluded feature
                                  ('HP:0001257',  # Spasticity
                                   'HetSingleVar',
-                                  PatientCategories.NO),
+                                  "No"),
                              ])
     def test_phenotype_predicate__present_or_excluded(
             self,
@@ -41,15 +38,16 @@ def test_phenotype_predicate__present_or_excluded(
             hpo: hpotk.MinimalOntology,
             curie: str,
             patient_id: str,
-            expected: PatientCategory,
+            expected: str,
     ):
         patient = find_patient(patient_id, toy_cohort)
         term_id = hpotk.TermId.from_curie(curie)
         predicate = HpoPredicate(hpo=hpo, query=term_id)
         actual = predicate.test(patient)
 
+        assert actual is not None
         assert actual.phenotype == term_id
-        assert actual.category == expected
+        assert actual.category.name == expected
 
     def test_phenotype_predicate__unknown(
             self,
@@ -70,13 +68,13 @@ class TestDiseasePresencePredicate:
     @pytest.mark.parametrize(
         'patient_id, patient_category',
         [
-            ('HetSingleVar', PatientCategories.YES),
-            ('HomoVar', PatientCategories.NO),
+            ('HetSingleVar', "Yes"),
+            ('HomoVar', "No"),
         ])
     def test_disease_predicate(
         self,
         patient_id: str,
-        patient_category: PatientCategories,
+        patient_category: str,
         toy_cohort: Cohort,
 
     ):
@@ -84,11 +82,7 @@ def test_disease_predicate(
         disease_id = hpotk.TermId.from_curie("OMIM:148050")
         predicate = DiseasePresencePredicate(disease_id)
         actual = predicate.test(patient)
-        assert actual.phenotype == disease_id
-        assert actual.category == patient_category
-
 
-
-@pytest.fixture(scope='module')
-def protein_predicates(protein_test_service: ProteinMetadataService) -> ProteinPredicates:
-    return ProteinPredicates(protein_metadata_service=protein_test_service)
+        assert actual is not None
+        assert actual.phenotype == disease_id
+        assert actual.category.name == patient_category
diff --git a/tests/test_tutorial.py b/tests/test_tutorial.py
deleted file mode 100644
index b592f1f8..00000000
--- a/tests/test_tutorial.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import pytest
-import ppktstore
-import hpotk
-
-from gpsea.model import Cohort, VariantEffect
-from gpsea.preprocessing import configure_caching_cohort_creator, CohortCreator, load_phenopackets
-from gpsea.analysis.pcats import HpoTermAnalysis
-from gpsea.analysis.mtc_filter import HpoMtcFilter
-from gpsea.analysis.pcats.stats import FisherExactTest
-from gpsea.analysis.predicate.genotype import VariantPredicates, groups_predicate
-from gpsea.analysis.predicate.phenotype import prepare_predicates_for_terms_of_interest
-
-
-class TestTutorial:
-
-    @pytest.fixture(scope='class')
-    def cohort_creator(
-        self,
-        hpo: hpotk.MinimalOntology,
-    ) -> CohortCreator:
-        return configure_caching_cohort_creator(
-            hpo=hpo,
-        )
-
-    @pytest.fixture(scope='class')
-    def cohort(
-        self,
-        cohort_creator: CohortCreator,
-    ) -> Cohort:
-        registry = ppktstore.registry.configure_phenopacket_registry()
-        with registry.open_phenopacket_store('0.1.18') as ps:
-            phenopackets = tuple(ps.iter_cohort_phenopackets('TBX5'))
-
-        cohort, _ = load_phenopackets(
-            phenopackets=phenopackets,
-            cohort_creator=cohort_creator,
-        )
-        return cohort
-
-    @pytest.fixture
-    def mtc_filter(
-        self,
-        hpo: hpotk.MinimalOntology,
-    ) -> HpoMtcFilter:
-        return HpoMtcFilter.default_filter(
-            hpo=hpo,
-            term_frequency_threshold=0.2,
-        )
-
-    @pytest.fixture
-    def hpo_term_analysis(
-        self,
-        mtc_filter,
-    ) -> HpoTermAnalysis:
-        return HpoTermAnalysis(
-            count_statistic=FisherExactTest(),
-            mtc_filter=mtc_filter,
-            mtc_correction='fdr_bh',
-            mtc_alpha=0.05,
-        )
-
-    @pytest.mark.skip('Just for manual debugging')
-    def test_compare_genotype_vs_phenotype(
-        self,
-        hpo: hpotk.MinimalOntology,
-        cohort: Cohort,
-        hpo_term_analysis: HpoTermAnalysis,
-    ):
-        tx_id = 'NM_181486.4'
-
-        gt_predicate = groups_predicate(
-            predicates=(
-                VariantPredicates.variant_effect(VariantEffect.MISSENSE_VARIANT, tx_id),
-                VariantPredicates.variant_effect(VariantEffect.FRAMESHIFT_VARIANT, tx_id),
-            ),
-            group_names=('Missense', 'Frameshift',),
-        )
-        pheno_predicates = prepare_predicates_for_terms_of_interest(
-            cohort=cohort,
-            hpo=hpo,
-            min_n_of_patients_with_term=2,
-        )
-
-        result = hpo_term_analysis.compare_genotype_vs_phenotypes(
-            cohort=cohort,
-            gt_predicate=gt_predicate,
-            pheno_predicates=pheno_predicates,
-        )
-
-        assert result is not None
diff --git a/tests/view/test_stats.py b/tests/view/test_stats.py
index f557a3e7..22ccb44d 100644
--- a/tests/view/test_stats.py
+++ b/tests/view/test_stats.py
@@ -5,7 +5,7 @@
 import pandas as pd
 
 from gpsea.analysis.pcats import HpoTermAnalysisResult
-from gpsea.analysis.predicate import PatientCategories
+from gpsea.analysis.pcats.stats import FisherExactTest
 from gpsea.analysis.predicate.genotype import GenotypePolyPredicate
 from gpsea.analysis.predicate.phenotype import HpoPredicate
 from gpsea.analysis.mtc_filter import PhenotypeMtcResult
@@ -20,39 +20,42 @@ def hpo_term_analysis_result(
         hpo: hpotk.MinimalOntology,
         suox_gt_predicate: GenotypePolyPredicate,
     ) -> HpoTermAnalysisResult:
+        is_arachnodactyly = HpoPredicate(
+            hpo=hpo,
+            query=hpotk.TermId.from_curie('HP:0001166'),  # Arachnodactyly
+        )
+        is_seizure = HpoPredicate(
+            hpo=hpo,
+            query=hpotk.TermId.from_curie('HP:0001250'),  # Seizure
+        )
         return HpoTermAnalysisResult(
+            gt_predicate=suox_gt_predicate,
+            statistic=FisherExactTest(),
+            mtc_correction='fdr_bh',
             pheno_predicates=(
-                HpoPredicate(
-                    hpo=hpo,
-                    query=hpotk.TermId.from_curie('HP:0001166'),  # Arachnodactyly
-                ),
-                HpoPredicate(
-                    hpo=hpo,
-                    query=hpotk.TermId.from_curie('HP:0001250'),  # Seizure
-                ),
+                is_arachnodactyly,
+                is_seizure,
             ),
             n_usable=(40, 20),
             all_counts=(
                 pd.DataFrame(
                     data=[[10, 5], [10, 15]],
-                    index=pd.Index((PatientCategories.YES, PatientCategories.NO,)),
+                    index=pd.Index(is_arachnodactyly.get_categories()),
                     columns=pd.Index(suox_gt_predicate.get_categories())
                 ),
                 pd.DataFrame(
                     data=[[5, 0], [5, 10]],
-                    index=pd.Index((PatientCategories.YES, PatientCategories.NO,)),
+                    index=pd.Index(is_seizure.get_categories()),
                     columns=pd.Index(suox_gt_predicate.get_categories())
                 ),
             ),
             pvals=(math.nan, .005,),
             corrected_pvals=(math.nan, .01),
-            gt_predicate=suox_gt_predicate,
             mtc_filter_name='Random MTC filter',
             mtc_filter_results=(
                 PhenotypeMtcResult.fail("RMF01", "Not too interesting"),
                 PhenotypeMtcResult.ok(),
             ),
-            mtc_name='fdr_bh',
         )
 
     @pytest.fixture
@@ -67,4 +70,4 @@ def test_process(
     ):
         report = stats_viewer.process(result=hpo_term_analysis_result)
         with open('mtc_stats.html', 'w') as fh:
-            fh.write(report)
+            report.write(fh)